2424import logging
2525import os
2626import pathlib
27+ import subprocess
2728import sys
28- from typing import TYPE_CHECKING , List , Set , Type
29+ from contextlib import contextmanager
30+ from typing import TYPE_CHECKING , Any , List , Set , Type , Union
2931
3032from rich .markdown import Markdown
3133
3234from ansiblelint import cli , formatters
3335from ansiblelint .color import console , console_stderr
36+ from ansiblelint .file_utils import cwd
3437from ansiblelint .generate_docs import rules_as_rich , rules_as_rst
3538from ansiblelint .rules import RulesCollection
3639from ansiblelint .runner import Runner
@@ -96,8 +99,9 @@ def report_outcome(matches: List["MatchError"], options) -> int:
9699# .ansible-lint
97100warn_list: # or 'skip_list' to silence them completely
98101"""
102+ matches_unignored = [match for match in matches if not match .ignored ]
99103
100- matched_rules = {match .rule .id : match .rule for match in matches }
104+ matched_rules = {match .rule .id : match .rule for match in matches_unignored }
101105 for id in sorted (matched_rules .keys ()):
102106 if {id , * matched_rules [id ].tags }.isdisjoint (options .warn_list ):
103107 msg += f" - '{ id } ' # { matched_rules [id ].shortdesc } \n "
@@ -151,6 +155,76 @@ def main() -> int:
151155 skip .update (str (s ).split (',' ))
152156 options .skip_list = frozenset (skip )
153157
158+ matches = _get_matches (rules , options )
159+
160+ # Assure we do not print duplicates and the order is consistent
161+ matches = sorted (set (matches ))
162+
163+ mark_as_success = False
164+ if matches and options .progressive :
165+ _logger .info (
166+ "Matches found, running again on previous revision in order to detect regressions" )
167+ with _previous_revision ():
168+ old_matches = _get_matches (rules , options )
169+ # remove old matches from current list
170+ matches_delta = list (set (matches ) - set (old_matches ))
171+ if len (matches_delta ) == 0 :
172+ _logger .warning (
173+ "Total violations not increased since previous "
174+ "commit, will mark result as success. (%s -> %s)" ,
175+ len (old_matches ), len (matches_delta ))
176+ mark_as_success = True
177+
178+ ignored = 0
179+ for match in matches :
180+ # if match is not new, mark is as ignored
181+ if match not in matches_delta :
182+ match .ignored = True
183+ ignored += 1
184+ if ignored :
185+ _logger .warning (
186+ "Marked %s previously known violation(s) as ignored due to"
187+ " progressive mode." , ignored )
188+
189+ _render_matches (matches , options , formatter , cwd )
190+
191+ if matches and not mark_as_success :
192+ return report_outcome (matches , options = options )
193+ else :
194+ return 0
195+
196+
197+ def _render_matches (
198+ matches : List ,
199+ options : "Namespace" ,
200+ formatter : Any ,
201+ cwd : Union [str , pathlib .Path ]):
202+
203+ ignored_matches = [match for match in matches if match .ignored ]
204+ fatal_matches = [match for match in matches if not match .ignored ]
205+ # Displayed ignored matches first
206+ if ignored_matches :
207+ _logger .warning (
208+ "Listing %s violation(s) marked as ignored, likely already known" ,
209+ len (ignored_matches ))
210+ for match in ignored_matches :
211+ if match .ignored :
212+ print (formatter .format (match , options .colored ))
213+ if fatal_matches :
214+ _logger .warning ("Listing %s violation(s) that are fatal" , len (fatal_matches ))
215+ for match in fatal_matches :
216+ if not match .ignored :
217+ print (formatter .format (match , options .colored ))
218+
219+ # If run under GitHub Actions we also want to emit output recognized by it.
220+ if os .getenv ('GITHUB_ACTIONS' ) == 'true' and os .getenv ('GITHUB_WORKFLOW' ):
221+ formatter = formatters .AnnotationsFormatter (cwd , True )
222+ for match in matches :
223+ print (formatter .format (match ))
224+
225+
226+ def _get_matches (rules : RulesCollection , options : "Namespace" ) -> list :
227+
154228 if not options .playbook :
155229 # no args triggers auto-detection mode
156230 playbooks = get_playbooks_and_roles (options = options )
@@ -164,23 +238,26 @@ def main() -> int:
164238 options .skip_list , options .exclude_paths ,
165239 options .verbosity , checked_files )
166240 matches .extend (runner .run ())
167-
168- # Assure we do not print duplicates and the order is consistent
169- matches = sorted (set (matches ))
170-
171- for match in matches :
172- print (formatter .format (match , options .colored ))
173-
174- # If run under GitHub Actions we also want to emit output recognized by it.
175- if os .getenv ('GITHUB_ACTIONS' ) == 'true' and os .getenv ('GITHUB_WORKFLOW' ):
176- formatter = formatters .AnnotationsFormatter (cwd , True )
177- for match in matches :
178- print (formatter .format (match ))
179-
180- if matches :
181- return report_outcome (matches , options = options )
182- else :
183- return 0
241+ return matches
242+
243+
244+ @contextmanager
245+ def _previous_revision ():
246+ """Create or update a temporary workdir containing the previous revision."""
247+ worktree_dir = ".cache/old-rev"
248+ revision = subprocess .run (
249+ ["git" , "rev-parse" , "HEAD^1" ],
250+ check = True ,
251+ universal_newlines = True ,
252+ stdout = subprocess .PIPE ,
253+ stderr = subprocess .DEVNULL ,
254+ ).stdout
255+ p = pathlib .Path (worktree_dir )
256+ p .mkdir (parents = True , exist_ok = True )
257+ os .system (f"git worktree add -f { worktree_dir } 2>/dev/null" )
258+ with cwd (worktree_dir ):
259+ os .system (f"git checkout { revision } " )
260+ yield
184261
185262
186263if __name__ == "__main__" :
0 commit comments