1212// See the License for the specific language governing permissions and
1313// limitations under the License.
1414
15- package gitrepo
15+ package conventionalcommits
1616
1717import (
1818 "fmt"
@@ -32,10 +32,12 @@ type ConventionalCommit struct {
3232 Description string
3333 // Body is the long-form description of the change.
3434 Body string
35- // Footers contain metadata (e.g. ,"BREAKING CHANGE", "Reviewed-by").
35+ // Footers contain metadata (e.g,"BREAKING CHANGE", "Reviewed-by").
3636 Footers map [string ]string
3737 // IsBreaking indicates if the commit introduces a breaking change.
3838 IsBreaking bool
39+ // IsNested indicates if the commit is a nested commit.
40+ IsNested bool
3941 // SHA is the full commit hash.
4042 SHA string
4143}
@@ -137,19 +139,111 @@ func parseFooters(footerLines []string) (footers map[string]string, isBreaking b
137139 return footers , isBreaking
138140}
139141
140- // ParseCommit parses a single commit message and returns a ConventionalCommit.
141- // If the commit message does not follow the conventional commit format, it
142- // logs a warning and returns a nil commit and no error.
143- func ParseCommit (message , hashString string ) (* ConventionalCommit , error ) {
144- trimmedMessage := strings .TrimSpace (message )
142+ const (
143+ beginCommitOverride = "BEGIN_COMMIT_OVERRIDE"
144+ endCommitOverride = "END_COMMIT_OVERRIDE"
145+ beginNestedCommit = "BEGIN_NESTED_COMMIT"
146+ endNestedCommit = "END_NESTED_COMMIT"
147+ )
148+
149+ func extractCommitMessageOverride (message string ) string {
150+ beginIndex := strings .Index (message , beginCommitOverride )
151+ if beginIndex == - 1 {
152+ return message
153+ }
154+ afterBegin := message [beginIndex + len (beginCommitOverride ):]
155+ endIndex := strings .Index (afterBegin , endCommitOverride )
156+ if endIndex == - 1 {
157+ return message
158+ }
159+ return strings .TrimSpace (afterBegin [:endIndex ])
160+ }
161+
162+ // commitPart holds the raw string of a commit message and whether it's nested.
163+ type commitPart struct {
164+ message string
165+ isNested bool
166+ }
167+
168+ func extractCommitParts (message string ) []commitPart {
169+ parts := strings .Split (message , beginNestedCommit )
170+ var commitParts []commitPart
171+
172+ // The first part is the primary commit.
173+ if len (parts ) > 0 && strings .TrimSpace (parts [0 ]) != "" {
174+ commitParts = append (commitParts , commitPart {
175+ message : strings .TrimSpace (parts [0 ]),
176+ isNested : false ,
177+ })
178+ }
179+
180+ // The rest of the parts are nested commits.
181+ for i := 1 ; i < len (parts ); i ++ {
182+ nestedPart := parts [i ]
183+ endIndex := strings .Index (nestedPart , endNestedCommit )
184+ if endIndex == - 1 {
185+ // Malformed, ignore.
186+ continue
187+ }
188+ commitStr := strings .TrimSpace (nestedPart [:endIndex ])
189+ if commitStr == "" {
190+ continue
191+ }
192+ commitParts = append (commitParts , commitPart {
193+ message : commitStr ,
194+ isNested : true ,
195+ })
196+ }
197+ return commitParts
198+ }
199+
200+ // ParseCommits parses a commit message into a slice of ConventionalCommit structs.
201+ //
202+ // It supports an override block wrapped in BEGIN_COMMIT_OVERRIDE and
203+ // END_COMMIT_OVERRIDE. If found, this block takes precedence, and only its
204+ // content will be parsed.
205+ //
206+ // The message can also contain multiple nested commits, each wrapped in
207+ // BEGIN_NESTED_COMMIT and END_NESTED_COMMIT markers.
208+ //
209+ // Malformed override or nested blocks (e.g., with a missing end marker) are
210+ // ignored. Any commit part that is found but fails to parse as a valid
211+ // conventional commit is logged and skipped.
212+ func ParseCommits (message , hashString string ) ([]* ConventionalCommit , error ) {
213+ if strings .TrimSpace (message ) == "" {
214+ return nil , fmt .Errorf ("empty commit message" )
215+ }
216+ message = extractCommitMessageOverride (message )
217+
218+ var commits []* ConventionalCommit
219+
220+ for _ , part := range extractCommitParts (message ) {
221+ c , err := parseSimpleCommit (part , hashString )
222+ if err != nil {
223+ slog .Warn ("failed to parse commit part" , "commit" , part .message , "error" , err )
224+ continue
225+ }
226+
227+ if c != nil {
228+ commits = append (commits , c )
229+ }
230+ }
231+
232+ return commits , nil
233+ }
234+
235+ // parseSimpleCommit parses a simple commit message and returns a ConventionalCommit.
236+ // A simple commit message is commit that does not include override or nested commits.
237+ func parseSimpleCommit (commitPart commitPart , hashString string ) (* ConventionalCommit , error ) {
238+ trimmedMessage := strings .TrimSpace (commitPart .message )
145239 if trimmedMessage == "" {
146240 return nil , fmt .Errorf ("empty commit message" )
147241 }
148242 lines := strings .Split (trimmedMessage , "\n " )
149243
150244 header , ok := parseHeader (lines [0 ])
151245 if ! ok {
152- slog .Warn ("Invalid conventional commit message" , "message" , message , "hash" , hashString )
246+ slog .Warn ("Invalid conventional commit message" , "message" , commitPart . message , "hash" , hashString )
153247 return nil , nil
154248 }
155249
@@ -164,6 +258,7 @@ func ParseCommit(message, hashString string) (*ConventionalCommit, error) {
164258 Body : strings .TrimSpace (strings .Join (bodyLines , "\n " )),
165259 Footers : footers ,
166260 IsBreaking : header .IsBreaking || footerIsBreaking ,
261+ IsNested : commitPart .isNested ,
167262 SHA : hashString ,
168263 }, nil
169264}
0 commit comments