1*b32fbb63SXin Li#!/usr/bin/python3 2*b32fbb63SXin Li# 3*b32fbb63SXin Li# Copyright (C) 2021 The Android Open Source Project 4*b32fbb63SXin Li# 5*b32fbb63SXin Li# Licensed under the Apache License, Version 2.0 (the "License"); 6*b32fbb63SXin Li# you may not use this file except in compliance with the License. 7*b32fbb63SXin Li# You may obtain a copy of the License at 8*b32fbb63SXin Li# 9*b32fbb63SXin Li# http://www.apache.org/licenses/LICENSE-2.0 10*b32fbb63SXin Li# 11*b32fbb63SXin Li# Unless required by applicable law or agreed to in writing, software 12*b32fbb63SXin Li# distributed under the License is distributed on an "AS IS" BASIS, 13*b32fbb63SXin Li# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14*b32fbb63SXin Li# See the License for the specific language governing permissions and 15*b32fbb63SXin Li# limitations under the License. 16*b32fbb63SXin Li"""Utilities for comparing two version of a codebase.""" 17*b32fbb63SXin Li 18*b32fbb63SXin Liimport argparse 19*b32fbb63SXin Liimport difflib 20*b32fbb63SXin Liimport filecmp 21*b32fbb63SXin Liimport os 22*b32fbb63SXin Liimport pathlib 23*b32fbb63SXin Liimport re 24*b32fbb63SXin Li 25*b32fbb63SXin Li 26*b32fbb63SXin Liclass FileStat: 27*b32fbb63SXin Li """File statistics class for a file.""" 28*b32fbb63SXin Li 29*b32fbb63SXin Li NON_TEXT = 0 30*b32fbb63SXin Li TEXT = 1 31*b32fbb63SXin Li 32*b32fbb63SXin Li def __init__(self, file_path): 33*b32fbb63SXin Li """Initializes with a file path string.""" 34*b32fbb63SXin Li if file_path: 35*b32fbb63SXin Li self.file_name = str(file_path) 36*b32fbb63SXin Li self.size = file_path.stat().st_size 37*b32fbb63SXin Li else: 38*b32fbb63SXin Li self.file_name = '' 39*b32fbb63SXin Li self.size = 0 40*b32fbb63SXin Li 41*b32fbb63SXin Li self.line_cnt = 0 42*b32fbb63SXin Li self.group_cnt = 0 43*b32fbb63SXin Li self.add_line_cnt = 0 44*b32fbb63SXin Li self.remove_line_cnt = 0 45*b32fbb63SXin Li self.replace_line_cnt = 0 46*b32fbb63SXin Li 47*b32fbb63SXin Li @staticmethod 48*b32fbb63SXin Li def get_csv_header(prefix=None): 49*b32fbb63SXin Li """Returns CSV header string.""" 50*b32fbb63SXin Li cols = ['file', 'size', 'line', 'group', 'add', 'remove', 'replace'] 51*b32fbb63SXin Li if prefix: 52*b32fbb63SXin Li return ','.join('{0}_{1}'.format(prefix, c) for c in cols) 53*b32fbb63SXin Li else: 54*b32fbb63SXin Li return ','.join(c for c in cols) 55*b32fbb63SXin Li 56*b32fbb63SXin Li def get_csv_str(self, strip_dir_len=0): 57*b32fbb63SXin Li """Returns the file statistic CSV string.""" 58*b32fbb63SXin Li name = self.file_name[strip_dir_len:] 59*b32fbb63SXin Li csv = [ 60*b32fbb63SXin Li FileStat.no_comma(name), self.size, self.line_cnt, self.group_cnt, 61*b32fbb63SXin Li self.add_line_cnt, self.remove_line_cnt, self.replace_line_cnt 62*b32fbb63SXin Li ] 63*b32fbb63SXin Li return ','.join(str(i) for i in csv) 64*b32fbb63SXin Li 65*b32fbb63SXin Li @staticmethod 66*b32fbb63SXin Li def no_comma(astr): 67*b32fbb63SXin Li """Replaces , with _.""" 68*b32fbb63SXin Li return astr.replace(',', '_') 69*b32fbb63SXin Li 70*b32fbb63SXin Li 71*b32fbb63SXin Liclass DiffStat: 72*b32fbb63SXin Li """Diff statistic class for 2 versions of a file.""" 73*b32fbb63SXin Li 74*b32fbb63SXin Li SAME = 0 75*b32fbb63SXin Li NEW = 1 76*b32fbb63SXin Li REMOVED = 2 77*b32fbb63SXin Li MODIFIED = 3 78*b32fbb63SXin Li INCOMPARABLE = 4 79*b32fbb63SXin Li 80*b32fbb63SXin Li def __init__(self, common_name, old_file_stat, new_file_stat, state): 81*b32fbb63SXin Li """Initializes with the common names & etc.""" 82*b32fbb63SXin Li self.old_file_stat = old_file_stat 83*b32fbb63SXin Li self.new_file_stat = new_file_stat 84*b32fbb63SXin Li self.name = common_name 85*b32fbb63SXin Li self.ext = os.path.splitext(self.name)[1].lstrip('.') 86*b32fbb63SXin Li self.state = state 87*b32fbb63SXin Li self.file_type = FileStat.NON_TEXT 88*b32fbb63SXin Li 89*b32fbb63SXin Li def add_diff_stat(self, diff_lines): 90*b32fbb63SXin Li """Adds the statistic by the diff lines.""" 91*b32fbb63SXin Li # These align with https://github.com/python/cpython/blob/3.9/Lib/difflib.py 92*b32fbb63SXin Li old_pattern = re.compile(r'\*{3} (.*)') 93*b32fbb63SXin Li new_pattern = re.compile(r'-{3} (.*)') 94*b32fbb63SXin Li group_separator = '***************' 95*b32fbb63SXin Li old_group_header = re.compile(r'\*{3} (\d*),(\d*) \*{4}') 96*b32fbb63SXin Li new_group_header = re.compile(r'-{3} (\d*),(\d*) -{4}') 97*b32fbb63SXin Li 98*b32fbb63SXin Li # section 0 is old verion & 1 is new verion 99*b32fbb63SXin Li section = -1 100*b32fbb63SXin Li diff_stats = [self.old_file_stat, self.new_file_stat] 101*b32fbb63SXin Li in_group = False 102*b32fbb63SXin Li 103*b32fbb63SXin Li h1m = old_pattern.match(diff_lines[0]) 104*b32fbb63SXin Li if not h1m: 105*b32fbb63SXin Li print('ERROR: wrong diff header line 1: %s' % diff_lines[0]) 106*b32fbb63SXin Li return 107*b32fbb63SXin Li 108*b32fbb63SXin Li h2m = new_pattern.match(diff_lines[1]) 109*b32fbb63SXin Li if not h2m: 110*b32fbb63SXin Li print('ERROR: wrong diff header line 2: %s' % diff_lines[1]) 111*b32fbb63SXin Li return 112*b32fbb63SXin Li 113*b32fbb63SXin Li for line in diff_lines[2:]: 114*b32fbb63SXin Li if in_group: 115*b32fbb63SXin Li if line.startswith(' '): 116*b32fbb63SXin Li # equal 117*b32fbb63SXin Li continue 118*b32fbb63SXin Li elif line.startswith('! '): 119*b32fbb63SXin Li # replace 120*b32fbb63SXin Li diff_stats[section].replace_line_cnt += 1 121*b32fbb63SXin Li continue 122*b32fbb63SXin Li elif line.startswith('+ '): 123*b32fbb63SXin Li # add 124*b32fbb63SXin Li diff_stats[section].add_line_cnt += 1 125*b32fbb63SXin Li continue 126*b32fbb63SXin Li elif line.startswith('- '): 127*b32fbb63SXin Li # removed 128*b32fbb63SXin Li diff_stats[section].remove_line_cnt += 1 129*b32fbb63SXin Li continue 130*b32fbb63SXin Li 131*b32fbb63SXin Li oghm = old_group_header.match(line) 132*b32fbb63SXin Li if oghm: 133*b32fbb63SXin Li section = 0 134*b32fbb63SXin Li diff_stats[section].group_cnt += 1 135*b32fbb63SXin Li continue 136*b32fbb63SXin Li 137*b32fbb63SXin Li nghm = new_group_header.match(line) 138*b32fbb63SXin Li if nghm: 139*b32fbb63SXin Li section = 1 140*b32fbb63SXin Li diff_stats[section].group_cnt += 1 141*b32fbb63SXin Li continue 142*b32fbb63SXin Li 143*b32fbb63SXin Li if line.startswith(group_separator): 144*b32fbb63SXin Li in_group = True 145*b32fbb63SXin Li continue 146*b32fbb63SXin Li 147*b32fbb63SXin Li 148*b32fbb63SXin Liclass ChangeReport: 149*b32fbb63SXin Li """Change report class for the diff statistics on 2 versions of a codebase. 150*b32fbb63SXin Li 151*b32fbb63SXin Li Attributes: 152*b32fbb63SXin Li old_dir: The old codebase dir path string. 153*b32fbb63SXin Li new_dir: The new codebase dir path string. 154*b32fbb63SXin Li dircmp: The dircmp object 155*b32fbb63SXin Li group_cnt: How many diff groups. 156*b32fbb63SXin Li add_line_cnt: How many lines are added. 157*b32fbb63SXin Li remove_line_cnt: How many lines are removed. 158*b32fbb63SXin Li replace_line_cnt: Hoe many lines are changed. 159*b32fbb63SXin Li """ 160*b32fbb63SXin Li 161*b32fbb63SXin Li def __init__(self, old_dir, new_dir, ignores=None, state_filter=None): 162*b32fbb63SXin Li """Initializes with old & new dir path strings.""" 163*b32fbb63SXin Li self.old_dir = os.path.abspath(old_dir) 164*b32fbb63SXin Li self._old_dir_prefix_len = len(self.old_dir) + 1 165*b32fbb63SXin Li self.new_dir = os.path.abspath(new_dir) 166*b32fbb63SXin Li self._new_dir_prefix_len = len(self.new_dir) + 1 167*b32fbb63SXin Li if ignores: 168*b32fbb63SXin Li self._ignores = ignores.split(',') 169*b32fbb63SXin Li self._ignores.extend(filecmp.DEFAULT_IGNORES) 170*b32fbb63SXin Li else: 171*b32fbb63SXin Li self._ignores = filecmp.DEFAULT_IGNORES 172*b32fbb63SXin Li 173*b32fbb63SXin Li if state_filter: 174*b32fbb63SXin Li self._state_filter = list(map(int, state_filter.split(','))) 175*b32fbb63SXin Li else: 176*b32fbb63SXin Li self._state_filter = [0, 1, 2, 3, 4] 177*b32fbb63SXin Li 178*b32fbb63SXin Li self._do_same = DiffStat.SAME in self._state_filter 179*b32fbb63SXin Li self._do_new = DiffStat.NEW in self._state_filter 180*b32fbb63SXin Li self._do_removed = DiffStat.REMOVED in self._state_filter 181*b32fbb63SXin Li self._do_moeified = DiffStat.MODIFIED in self._state_filter 182*b32fbb63SXin Li self._do_incomparable = DiffStat.INCOMPARABLE in self._state_filter 183*b32fbb63SXin Li 184*b32fbb63SXin Li self.dircmp = filecmp.dircmp( 185*b32fbb63SXin Li self.old_dir, self.new_dir, ignore=self._ignores) 186*b32fbb63SXin Li self._diff_stats = [] 187*b32fbb63SXin Li self._diff_stat_lines = [] 188*b32fbb63SXin Li self._diff_lines = [] 189*b32fbb63SXin Li self._processed_cnt = 0 190*b32fbb63SXin Li self._common_dir_len = ChangeReport.get_common_path_len( 191*b32fbb63SXin Li self.old_dir, self.new_dir) 192*b32fbb63SXin Li 193*b32fbb63SXin Li @staticmethod 194*b32fbb63SXin Li def get_common_path_len(dir1, dir2): 195*b32fbb63SXin Li """Gets the length of the common path of old & new folders.""" 196*b32fbb63SXin Li sep = os.path.sep 197*b32fbb63SXin Li last_sep_pos = 0 198*b32fbb63SXin Li for i in range(len(dir1)): 199*b32fbb63SXin Li if dir1[i] == sep: 200*b32fbb63SXin Li last_sep_pos = i 201*b32fbb63SXin Li if dir1[i] != dir2[i]: 202*b32fbb63SXin Li break 203*b32fbb63SXin Li return last_sep_pos + 1 204*b32fbb63SXin Li 205*b32fbb63SXin Li @staticmethod 206*b32fbb63SXin Li def get_diff_stat_header(): 207*b32fbb63SXin Li """Gets the diff statistic CSV header.""" 208*b32fbb63SXin Li return 'file,ext,text,state,{0},{1}\n'.format( 209*b32fbb63SXin Li FileStat.get_csv_header('new'), FileStat.get_csv_header('old')) 210*b32fbb63SXin Li 211*b32fbb63SXin Li def get_diff_stat_lines(self): 212*b32fbb63SXin Li """Gets the diff statistic CSV lines.""" 213*b32fbb63SXin Li if self._processed_cnt < 1: 214*b32fbb63SXin Li self._process_dircmp(self.dircmp) 215*b32fbb63SXin Li self._processed_cnt += 1 216*b32fbb63SXin Li 217*b32fbb63SXin Li self._diff_stat_lines = [] 218*b32fbb63SXin Li for diff_stat in self._diff_stats: 219*b32fbb63SXin Li self._diff_stat_lines.append('{0},{1},{2},{3},{4},{5}\n'.format( 220*b32fbb63SXin Li FileStat.no_comma(diff_stat.name), diff_stat.ext, 221*b32fbb63SXin Li diff_stat.file_type, diff_stat.state, 222*b32fbb63SXin Li diff_stat.new_file_stat.get_csv_str(self._common_dir_len), 223*b32fbb63SXin Li diff_stat.old_file_stat.get_csv_str(self._common_dir_len))) 224*b32fbb63SXin Li 225*b32fbb63SXin Li return self._diff_stat_lines 226*b32fbb63SXin Li 227*b32fbb63SXin Li def get_diff_lines(self): 228*b32fbb63SXin Li """Gets the diff output lines.""" 229*b32fbb63SXin Li if self._processed_cnt < 1: 230*b32fbb63SXin Li self._process_dircmp(self.dircmp) 231*b32fbb63SXin Li self._processed_cnt += 1 232*b32fbb63SXin Li return self._diff_lines 233*b32fbb63SXin Li 234*b32fbb63SXin Li def _process_dircmp(self, dircmp): 235*b32fbb63SXin Li """Compare all files in a dircmp object for diff statstics & output.""" 236*b32fbb63SXin Li if self._do_moeified: 237*b32fbb63SXin Li self._process_diff_files(dircmp) 238*b32fbb63SXin Li 239*b32fbb63SXin Li for subdir_dircmp in dircmp.subdirs.values(): 240*b32fbb63SXin Li rp = pathlib.Path(subdir_dircmp.right) 241*b32fbb63SXin Li lp = pathlib.Path(subdir_dircmp.left) 242*b32fbb63SXin Li if rp.is_symlink() or lp.is_symlink(): 243*b32fbb63SXin Li print('SKIP: symlink: {0} or {1}'.format(subdir_dircmp.right, 244*b32fbb63SXin Li subdir_dircmp.left)) 245*b32fbb63SXin Li continue 246*b32fbb63SXin Li self._process_dircmp(subdir_dircmp) 247*b32fbb63SXin Li 248*b32fbb63SXin Li if self._do_new: 249*b32fbb63SXin Li self._process_others(dircmp.right_only, dircmp.right, 250*b32fbb63SXin Li self._new_dir_prefix_len, DiffStat.NEW) 251*b32fbb63SXin Li if self._do_same: 252*b32fbb63SXin Li self._process_others(dircmp.same_files, dircmp.right, 253*b32fbb63SXin Li self._new_dir_prefix_len, DiffStat.SAME) 254*b32fbb63SXin Li if self._do_incomparable: 255*b32fbb63SXin Li self._process_others(dircmp.funny_files, dircmp.right, 256*b32fbb63SXin Li self._new_dir_prefix_len, DiffStat.INCOMPARABLE) 257*b32fbb63SXin Li if self._do_removed: 258*b32fbb63SXin Li self._process_others(dircmp.left_only, dircmp.left, 259*b32fbb63SXin Li self._old_dir_prefix_len, DiffStat.REMOVED) 260*b32fbb63SXin Li 261*b32fbb63SXin Li def _process_others(self, files, adir, prefix_len, state): 262*b32fbb63SXin Li """Processes files are not modified.""" 263*b32fbb63SXin Li empty_stat = FileStat(None) 264*b32fbb63SXin Li for file in files: 265*b32fbb63SXin Li file_path = pathlib.Path(adir, file) 266*b32fbb63SXin Li if file_path.is_symlink(): 267*b32fbb63SXin Li print('SKIP: symlink: {0}, {1}'.format(state, file_path)) 268*b32fbb63SXin Li continue 269*b32fbb63SXin Li elif file_path.is_dir(): 270*b32fbb63SXin Li flist = self._get_filtered_files(file_path) 271*b32fbb63SXin Li self._process_others(flist, adir, prefix_len, state) 272*b32fbb63SXin Li else: 273*b32fbb63SXin Li file_stat = FileStat(file_path) 274*b32fbb63SXin Li common_name = str(file_path)[prefix_len:] 275*b32fbb63SXin Li if state == DiffStat.REMOVED: 276*b32fbb63SXin Li diff_stat = DiffStat(common_name, file_stat, empty_stat, state) 277*b32fbb63SXin Li else: 278*b32fbb63SXin Li diff_stat = DiffStat(common_name, empty_stat, file_stat, state) 279*b32fbb63SXin Li try: 280*b32fbb63SXin Li with open(file_path, encoding='utf-8') as f: 281*b32fbb63SXin Li lines = f.readlines() 282*b32fbb63SXin Li file_stat.line_cnt = len(lines) 283*b32fbb63SXin Li file_type = FileStat.TEXT 284*b32fbb63SXin Li except UnicodeDecodeError: 285*b32fbb63SXin Li file_type = FileStat.NON_TEXT 286*b32fbb63SXin Li 287*b32fbb63SXin Li diff_stat.file_type = file_type 288*b32fbb63SXin Li self._diff_stats.append(diff_stat) 289*b32fbb63SXin Li 290*b32fbb63SXin Li def _process_diff_files(self, dircmp): 291*b32fbb63SXin Li """Processes files are modified.""" 292*b32fbb63SXin Li for file in dircmp.diff_files: 293*b32fbb63SXin Li old_file_path = pathlib.Path(dircmp.left, file) 294*b32fbb63SXin Li new_file_path = pathlib.Path(dircmp.right, file) 295*b32fbb63SXin Li self._diff_files(old_file_path, new_file_path) 296*b32fbb63SXin Li 297*b32fbb63SXin Li def _diff_files(self, old_file_path, new_file_path): 298*b32fbb63SXin Li """Diff old & new files.""" 299*b32fbb63SXin Li old_file_stat = FileStat(old_file_path) 300*b32fbb63SXin Li new_file_stat = FileStat(new_file_path) 301*b32fbb63SXin Li common_name = str(new_file_path)[self._new_dir_prefix_len:] 302*b32fbb63SXin Li diff_stat = DiffStat(common_name, old_file_stat, new_file_stat, 303*b32fbb63SXin Li DiffStat.MODIFIED) 304*b32fbb63SXin Li 305*b32fbb63SXin Li try: 306*b32fbb63SXin Li with open(old_file_path, encoding='utf-8') as f1: 307*b32fbb63SXin Li old_lines = f1.readlines() 308*b32fbb63SXin Li old_file_stat.line_cnt = len(old_lines) 309*b32fbb63SXin Li with open(new_file_path, encoding='utf-8') as f2: 310*b32fbb63SXin Li new_lines = f2.readlines() 311*b32fbb63SXin Li new_file_stat.line_cnt = len(new_lines) 312*b32fbb63SXin Li diff_lines = list( 313*b32fbb63SXin Li difflib.context_diff(old_lines, new_lines, old_file_path.name, 314*b32fbb63SXin Li new_file_path.name)) 315*b32fbb63SXin Li file_type = FileStat.TEXT 316*b32fbb63SXin Li if diff_lines: 317*b32fbb63SXin Li self._diff_lines.extend(diff_lines) 318*b32fbb63SXin Li diff_stat.add_diff_stat(diff_lines) 319*b32fbb63SXin Li else: 320*b32fbb63SXin Li print('WARNING: no diff lines on {0} {1}'.format( 321*b32fbb63SXin Li old_file_path, new_file_path)) 322*b32fbb63SXin Li 323*b32fbb63SXin Li except UnicodeDecodeError: 324*b32fbb63SXin Li file_type = FileStat.NON_TEXT 325*b32fbb63SXin Li 326*b32fbb63SXin Li diff_stat.file_type = file_type 327*b32fbb63SXin Li self._diff_stats.append(diff_stat) 328*b32fbb63SXin Li 329*b32fbb63SXin Li def _get_filtered_files(self, dir_path): 330*b32fbb63SXin Li """Returns a filtered file list.""" 331*b32fbb63SXin Li flist = [] 332*b32fbb63SXin Li for f in dir_path.glob('*'): 333*b32fbb63SXin Li if f.name not in self._ignores: 334*b32fbb63SXin Li if f.is_symlink(): 335*b32fbb63SXin Li print('SKIP: symlink: %s' % f) 336*b32fbb63SXin Li continue 337*b32fbb63SXin Li else: 338*b32fbb63SXin Li flist.append(f) 339*b32fbb63SXin Li return flist 340*b32fbb63SXin Li 341*b32fbb63SXin Li 342*b32fbb63SXin Lidef write_file(file, lines, header=None): 343*b32fbb63SXin Li """Write lines into a file.""" 344*b32fbb63SXin Li 345*b32fbb63SXin Li with open(file, 'w') as f: 346*b32fbb63SXin Li if header: 347*b32fbb63SXin Li f.write(header) 348*b32fbb63SXin Li 349*b32fbb63SXin Li f.writelines(lines) 350*b32fbb63SXin Li print('OUTPUT: {0}, {1} lines'.format(file, len(lines))) 351*b32fbb63SXin Li 352*b32fbb63SXin Li 353*b32fbb63SXin Lidef main(): 354*b32fbb63SXin Li parser = argparse.ArgumentParser( 355*b32fbb63SXin Li 'Generate a diff stat cvs file for 2 versions of a codebase') 356*b32fbb63SXin Li parser.add_argument('--old_dir', help='the old version codebase dir') 357*b32fbb63SXin Li parser.add_argument('--new_dir', help='the new version codebase dir') 358*b32fbb63SXin Li parser.add_argument( 359*b32fbb63SXin Li '--csv_file', required=False, help='the diff stat cvs file if to create') 360*b32fbb63SXin Li parser.add_argument( 361*b32fbb63SXin Li '--diff_output_file', 362*b32fbb63SXin Li required=False, 363*b32fbb63SXin Li help='the diff output file if to create') 364*b32fbb63SXin Li parser.add_argument( 365*b32fbb63SXin Li '--ignores', 366*b32fbb63SXin Li required=False, 367*b32fbb63SXin Li default='.repo,.git,.github,.idea,__MACOSX,.prebuilt_info', 368*b32fbb63SXin Li help='names to ignore') 369*b32fbb63SXin Li parser.add_argument( 370*b32fbb63SXin Li '--state_filter', 371*b32fbb63SXin Li required=False, 372*b32fbb63SXin Li default='1,2,3', 373*b32fbb63SXin Li help='csv diff states to process, 0:SAME, 1:NEW, 2:REMOVED, 3:MODIFIED, ' 374*b32fbb63SXin Li '4:INCOMPARABLE') 375*b32fbb63SXin Li 376*b32fbb63SXin Li args = parser.parse_args() 377*b32fbb63SXin Li 378*b32fbb63SXin Li if not os.path.isdir(args.old_dir): 379*b32fbb63SXin Li print('ERROR: %s does not exist.' % args.old_dir) 380*b32fbb63SXin Li exit() 381*b32fbb63SXin Li 382*b32fbb63SXin Li if not os.path.isdir(args.new_dir): 383*b32fbb63SXin Li print('ERROR: %s does not exist.' % args.new_dir) 384*b32fbb63SXin Li exit() 385*b32fbb63SXin Li 386*b32fbb63SXin Li change_report = ChangeReport(args.old_dir, args.new_dir, args.ignores, 387*b32fbb63SXin Li args.state_filter) 388*b32fbb63SXin Li if args.csv_file: 389*b32fbb63SXin Li write_file( 390*b32fbb63SXin Li args.csv_file, 391*b32fbb63SXin Li change_report.get_diff_stat_lines(), 392*b32fbb63SXin Li header=ChangeReport.get_diff_stat_header()) 393*b32fbb63SXin Li 394*b32fbb63SXin Li if args.diff_output_file: 395*b32fbb63SXin Li write_file(args.diff_output_file, change_report.get_diff_lines()) 396*b32fbb63SXin Li 397*b32fbb63SXin Li 398*b32fbb63SXin Liif __name__ == '__main__': 399*b32fbb63SXin Li main() 400