22# SPDX-License-Identifier: Apache-2.0 
33"""The user defined rules for gitlint.""" 
44
5+ import  re 
6+ 
57from  gitlint .rules  import  CommitRule , RuleViolation 
68
79
@@ -22,129 +24,113 @@ class EndsSigned(CommitRule):
2224    def  validate (self , commit ):
2325        r"""Validates Signed-off-by and Co-authored-by tags as Linux's scripts/checkpatch.pl 
2426
25-         >>> from gitlint.tests.base  import BaseTestCase  
27+         >>> from gitlint.git  import GitContext  
2628        >>> from gitlint.rules import RuleViolation 
2729        ... 
2830        >>> ends_signed = EndsSigned() 
31+         >>> miss_sob_follows_coab = "Missing 'Signed-off-by' following 'Co-authored-by'" 
32+         >>> miss_sob = "'Signed-off-by' not found in commit message body" 
33+         >>> non_sign = "Non 'Co-authored-by' or 'Signed-off-by' string found following 1st 'Signed-off-by'" 
34+         >>> email_no_match = "'Co-authored-by' and 'Signed-off-by' name/email do not match" 
2935        ... 
3036        >>> msg1 = ( 
3137        ... f"Title\n\nMessage.\n\n" 
3238        ... f"Signed-off-by: name <email@domain>" 
3339        ... ) 
34-         >>> commit1 = BaseTestCase.gitcommit (msg1) 
40+         >>> commit1 = GitContext.from_commit_msg (msg1).commits[0]  
3541        >>> ends_signed.validate(commit1) 
3642        [] 
3743        >>> msg2 = ( 
3844        ... f"Title\n\nMessage.\n\n" 
3945        ... f"Co-authored-by: name <email>\n\n" 
4046        ... f"Signed-off-by: name <email>" 
4147        ... ) 
42-         >>> commit2 = BaseTestCase.gitcommit (msg2) 
48+         >>> commit2 = GitContext.from_commit_msg (msg2).commits[0]  
4349        >>> ends_signed.validate(commit2) 
4450        [] 
45-         >>> msg3 = ( 
46-         ... f"Title\n\nMessage.\n\n" 
47-         ... ) 
48-         >>> commit3 = BaseTestCase.gitcommit(msg3) 
51+         >>> msg3 = f"Title\n\nMessage.\n\n" 
52+         >>> commit3 = GitContext.from_commit_msg(msg3).commits[0] 
4953        >>> vio3 = ends_signed.validate(commit3) 
50-         >>> vio_msg3 = ( 
51-         ... f"'Signed-off-by:' not found in commit message body" 
52-         ... ) 
53-         >>> vio3 == [RuleViolation("UC2", vio_msg3)] 
54+         >>> vio3 == [RuleViolation("UC2", miss_sob)] 
5455        True 
5556        >>> msg4 = ( 
5657        ... f"Title\n\nMessage.\n\n" 
5758        ... f"Signed-off-by: name <email@domain>\n\na sentence" 
5859        ... ) 
59-         >>> commit4 = BaseTestCase.gitcommit (msg4) 
60+         >>> commit4 = GitContext.from_commit_msg (msg4).commits[0]  
6061        >>> vio4 = ends_signed.validate(commit4) 
61-         >>> vio_msg4 = ( 
62-         ... f"Non 'Co-authored-by:' or 'Signed-off-by:' string found following 1st 'Signed-off-by:'" 
63-         ... ) 
64-         >>> vio4 == [RuleViolation("UC2", vio_msg4, None, 5)] 
62+         >>> vio4 == [RuleViolation("UC2", non_sign, None, 6)] 
6563        True 
6664        >>> msg5 = ( 
6765        ... f"Title\n\nMessage.\n\n" 
6866        ... f"Co-authored-by: name <email@domain>" 
6967        ... ) 
70-         >>> commit5 = BaseTestCase.gitcommit (msg5) 
68+         >>> commit5 = GitContext.from_commit_msg (msg5).commits[0]  
7169        >>> vio5 = ends_signed.validate(commit5) 
72-         >>> vio_msg5 = (  
73-         ... f"Missing 'Signed-off-by:' following 'Co-authored-by:'"  
74-         ... ) 
75-         >>> vio5 == [RuleViolation("UC2", vio_msg5, None, 2) ] 
70+         >>> vio5 == [  
71+         ...   RuleViolation("UC2", miss_sob, None, None),  
72+         ...   RuleViolation("UC2", miss_sob_follows_coab, None, 5 ) 
73+         ...  ] 
7674        True 
7775        >>> msg6 = ( 
7876        ... f"Title\n\nMessage.\n\n" 
7977        ... f"Co-authored-by: name <email@domain>\n\n" 
8078        ... f"Signed-off-by: different name <email@domain>" 
8179        ... ) 
82-         >>> commit6 = BaseTestCase.gitcommit (msg6) 
80+         >>> commit6 = GitContext.from_commit_msg (msg6).commits[0]  
8381        >>> vio6 = ends_signed.validate(commit6) 
84-         >>> vio_msg6 = ( 
85-         ... f"'Co-authored-by:' and 'Signed-off-by:' name/email do not match" 
86-         ... ) 
87-         >>> vio6 == [RuleViolation("UC2", vio_msg6, None, 6)] 
82+         >>> vio6 == [RuleViolation("UC2", email_no_match, None, 6)] 
8883        True 
8984        """ 
9085
9186        violations  =  []
9287
9388        # Utilities 
9489        def  vln (stmt , i ):
95-             return  RuleViolation (self .id , stmt , None , i )
96- 
97-         co_auth  =  "Co-authored-by:" 
98-         sig  =  "Signed-off-by:" 
90+             violations .append (RuleViolation (self .id , stmt , None , i ))
9991
100-         message_iter  =  enumerate (commit .message .original .split ("\n " ))
92+         coab  =  "Co-authored-by" 
93+         sob  =  "Signed-off-by" 
10194
102-         # Skip ahead to the first signoff or co-author tag 
103- 
104-         # Checks commit message contains a `Signed-off-by` string 
105-         for  i , line  in  message_iter :
106-             if  line .startswith (sig ) or  line .startswith (co_auth ):
107-                 break 
108-         else :
109-             # No signature was found in the message (before `message_iter` ended) 
110-             # This check here can have false-negatives (e.g. if the body ends with only 
111-             # a 'Co-authored-by' tag), but then below will realize that the co-authored-by 
112-             # tag isnt followed by a Signed-off-by tag and fail (and also the DCO check will 
113-             # complain). 
114-             violations .append (vln (f"'{ sig }  ' not found in commit message body" , None ))
115- 
116-         # Check that from here on out we only have signatures and co-authors, and that 
117-         # every co-author is immediately followed by a signature with the same name/email. 
118-         for  i , line  in  message_iter :
119-             if  line .startswith (co_auth ):
120-                 try :
121-                     _ , next_line  =  next (message_iter )
122-                 except  StopIteration :
123-                     violations .append (
124-                         vln (f"Missing '{ sig }  ' tag following '{ co_auth }  '" , i )
125-                     )
126-                 else :
127-                     if  not  next_line .startswith (sig ):
128-                         violations .append (
129-                             vln (f"Missing '{ sig }  ' tag following '{ co_auth }  '" , i  +  1 )
130-                         )
131-                         continue 
132- 
133-                     if  next_line .split (":" )[1 ].strip () !=  line .split (":" )[1 ].strip ():
134-                         violations .append (
135-                             vln (f"{ co_auth }   and { sig }   name/email do not match" , i  +  1 )
136-                         )
137-                 continue 
138- 
139-             if  line .startswith (sig ) or  not  line .strip ():
95+         # find trailers 
96+         trailers  =  []
97+         for  i , line  in  enumerate (commit .message .original .splitlines ()):
98+             # ignore empty lines 
99+             if  not  line :
140100                continue 
101+             match  =  re .match (r"([\w-]+):\s+(.*)" , line )
102+             if  match :
103+                 key , val  =  match .groups ()
104+                 trailers .append ((i , key , val ))
105+             else :
106+                 trailers .append ((i , "line" , line ))
107+         # artificial line so we can check any "previous line" rules 
108+         trailers .append ((trailers [- 1 ][0 ] +  1 , None , None ))
141109
142-             violations .append (
110+         # Checks commit message contains a `Signed-off-by` string 
111+         if  not  [x  for  x  in  trailers  if  x [1 ] ==  sob ]:
112+             vln (f"'{ sob }  ' not found in commit message body" , None )
113+ 
114+         prev_trailer , prev_value  =  None , None 
115+         sig_trailers  =  False 
116+         for  i , trailer , value  in  trailers :
117+             if  trailer  in  {sob , coab }:
118+                 sig_trailers  =  True 
119+             elif  trailer  not  in   {sob , coab , None } and  sig_trailers :
143120                vln (
144-                     f"Non '{ co_auth }  ' or '{ sig }  ' string found following 1st '{ sig }  '" ,
121+                     f"Non '{ coab }  ' or '{ sob }  ' string found following 1st '{ sob }  '" ,
145122                    i ,
146123                )
147-             )
124+             # Every co-author is immediately followed by a signature 
125+             if  prev_trailer  ==  coab :
126+                 if  trailer  !=  sob :
127+                     vln (f"Missing '{ sob }  ' following '{ coab }  '" , i )
128+                 else :
129+                     # with the same name/email. 
130+                     if  value  !=  prev_value :
131+                         vln (f"'{ coab }  ' and '{ sob }  ' name/email do not match" , i )
132+ 
133+             prev_trailer , prev_value  =  trailer , value 
148134
149135        # Return errors 
150136        return  violations 
0 commit comments