Add change_report.py am: b6e3a83f1b am: 651b20269f

Original change: https://android-review.googlesource.com/c/platform/tools/aadevtools/+/1700338

Change-Id: Iac64a4ddd435830643e0ea5bd7e97fe568c7cc48
diff --git a/README.md b/README.md
index d5a8cef..d99abcb 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,40 @@
 * clone_proj.sh to clone a git project for the unbundled development workflows
 in instead of the whole Android repo.
 
-## Chnage Reports
+## Change Reports
+
+### change_report.py
+change_report.py creates a diff statistic CSV file from 2 versions of a codebase.
+This is useful when the git commit history is somehow not obtainable. What you
+need is to get 2 versions of a codebase downloaded on your disk first.
+
+* You can compare specific folders of concern for a quick result, or when
+there is a code patch change.
+* This skips all symlinks & ignores common repository metadata folders, e.g.
+.git, etc.
+* It can take a long time & generates a large CSV file for the whole Android
+codebase & especially if they are many changes. For example:
+  * Android 11 QPR1 vs QPR2 takes more than 8 min. & generates a 5MB CSV file.
+  * Android 10 QPR3 vs Android 11 QPR2 takes more than 11 min. & generates a
+  95MB CSV file.
+* To reduce time, you should always remove **out**, the build output folder first.
+* For example, to compare Android 11 QPR1 vs QPR2 AOSP codebases on your disk.
+
+```
+python3 change_report.py --old_dir ~/android/android11-qpr1-release \
+  --new_dir ~/android/android11-qpr2-release \
+  --csv_file ~/change_reports/change_report_android11-qpr1-release_android11-qpr2-release.csv
+```
+
+* An output example: [change_report-new_vs_old_codebase.csv](dev/resource/change_report-new_vs_old_codebase.csv)
+is the change report between **dev/resource/old_codebase** and
+**new_codebase**.
+* The **states** are:
+  * SAME = 0
+  * NEW = 1
+  * REMOVED = 2
+  * MODIFIED = 3
+  * INCOMPARABLE = 4
 
 ### sysui_oem_diff.sh
 sysui_oem_diff.sh generates a summary of code changes between 2 revisions.
@@ -31,7 +64,7 @@
 * For example, to generate the change report for Android 11 to 10 QPR3: [sysui_gcar_android10-qpr3-release_android11-release.txt](dev/resource/sysui_gcar_android10-qpr3-release_android11-release.txt)
 
 ```
-$ ./sysui_oem_diff.sh ~/Android/android11-release remotes/aosp/android10-qpr3-release remotes/aosp/android11-release > sysui_gcar_android10-qpr3-release_android11-release.txt
+./sysui_oem_diff.sh ~/Android/android11-release remotes/aosp/android10-qpr3-release remotes/aosp/android11-release > sysui_gcar_android10-qpr3-release_android11-release.txt
 ```
 
 ## System Performance Tuning
diff --git a/dev/change_report.py b/dev/change_report.py
new file mode 100644
index 0000000..f6437c0
--- /dev/null
+++ b/dev/change_report.py
@@ -0,0 +1,399 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2021 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Utilities for comparing two version of a codebase."""
+
+import argparse
+import difflib
+import filecmp
+import os
+import pathlib
+import re
+
+
+class FileStat:
+  """File statistics class for a file."""
+
+  NON_TEXT = 0
+  TEXT = 1
+
+  def __init__(self, file_path):
+    """Initializes with a file path string."""
+    if file_path:
+      self.file_name = str(file_path)
+      self.size = file_path.stat().st_size
+    else:
+      self.file_name = ''
+      self.size = 0
+
+    self.line_cnt = 0
+    self.group_cnt = 0
+    self.add_line_cnt = 0
+    self.remove_line_cnt = 0
+    self.replace_line_cnt = 0
+
+  @staticmethod
+  def get_csv_header(prefix=None):
+    """Returns CSV header string."""
+    cols = ['file', 'size', 'line', 'group', 'add', 'remove', 'replace']
+    if prefix:
+      return ','.join('{0}_{1}'.format(prefix, c) for c in cols)
+    else:
+      return ','.join(c for c in cols)
+
+  def get_csv_str(self, strip_dir_len=0):
+    """Returns the file statistic CSV string."""
+    name = self.file_name[strip_dir_len:]
+    csv = [
+        FileStat.no_comma(name), self.size, self.line_cnt, self.group_cnt,
+        self.add_line_cnt, self.remove_line_cnt, self.replace_line_cnt
+    ]
+    return ','.join(str(i) for i in csv)
+
+  @staticmethod
+  def no_comma(astr):
+    """Replaces , with _."""
+    return astr.replace(',', '_')
+
+
+class DiffStat:
+  """Diff statistic class for 2 versions of a file."""
+
+  SAME = 0
+  NEW = 1
+  REMOVED = 2
+  MODIFIED = 3
+  INCOMPARABLE = 4
+
+  def __init__(self, common_name, old_file_stat, new_file_stat, state):
+    """Initializes with the common names & etc."""
+    self.old_file_stat = old_file_stat
+    self.new_file_stat = new_file_stat
+    self.name = common_name
+    self.ext = os.path.splitext(self.name)[1].lstrip('.')
+    self.state = state
+    self.file_type = FileStat.NON_TEXT
+
+  def add_diff_stat(self, diff_lines):
+    """Adds the statistic by the diff lines."""
+    # These align with https://github.com/python/cpython/blob/3.9/Lib/difflib.py
+    old_pattern = re.compile(r'\*{3} (.*)')
+    new_pattern = re.compile(r'-{3} (.*)')
+    group_separator = '***************'
+    old_group_header = re.compile(r'\*{3} (\d*),(\d*) \*{4}')
+    new_group_header = re.compile(r'-{3} (\d*),(\d*) -{4}')
+
+    # section 0 is old verion & 1 is new verion
+    section = -1
+    diff_stats = [self.old_file_stat, self.new_file_stat]
+    in_group = False
+
+    h1m = old_pattern.match(diff_lines[0])
+    if not h1m:
+      print('ERROR: wrong diff header line 1: %s' % diff_lines[0])
+      return
+
+    h2m = new_pattern.match(diff_lines[1])
+    if not h2m:
+      print('ERROR: wrong diff header line 2: %s' % diff_lines[1])
+      return
+
+    for line in diff_lines[2:]:
+      if in_group:
+        if line.startswith('  '):
+          # equal
+          continue
+        elif line.startswith('! '):
+          # replace
+          diff_stats[section].replace_line_cnt += 1
+          continue
+        elif line.startswith('+ '):
+          # add
+          diff_stats[section].add_line_cnt += 1
+          continue
+        elif line.startswith('- '):
+          # removed
+          diff_stats[section].remove_line_cnt += 1
+          continue
+
+      oghm = old_group_header.match(line)
+      if oghm:
+        section = 0
+        diff_stats[section].group_cnt += 1
+        continue
+
+      nghm = new_group_header.match(line)
+      if nghm:
+        section = 1
+        diff_stats[section].group_cnt += 1
+        continue
+
+      if line.startswith(group_separator):
+        in_group = True
+        continue
+
+
+class ChangeReport:
+  """Change report class for the diff statistics on 2 versions of a codebase.
+
+  Attributes:
+    old_dir: The old codebase dir path string.
+    new_dir: The new codebase dir path string.
+    dircmp: The dircmp object
+    group_cnt: How many diff groups.
+    add_line_cnt: How many lines are added.
+    remove_line_cnt: How many lines are removed.
+    replace_line_cnt: Hoe many lines are changed.
+  """
+
+  def __init__(self, old_dir, new_dir, ignores=None, state_filter=None):
+    """Initializes with old & new dir path strings."""
+    self.old_dir = os.path.abspath(old_dir)
+    self._old_dir_prefix_len = len(self.old_dir) + 1
+    self.new_dir = os.path.abspath(new_dir)
+    self._new_dir_prefix_len = len(self.new_dir) + 1
+    if ignores:
+      self._ignores = ignores.split(',')
+      self._ignores.extend(filecmp.DEFAULT_IGNORES)
+    else:
+      self._ignores = filecmp.DEFAULT_IGNORES
+
+    if state_filter:
+      self._state_filter = list(map(int, state_filter.split(',')))
+    else:
+      self._state_filter = [0, 1, 2, 3, 4]
+
+    self._do_same = DiffStat.SAME in self._state_filter
+    self._do_new = DiffStat.NEW in self._state_filter
+    self._do_removed = DiffStat.REMOVED in self._state_filter
+    self._do_moeified = DiffStat.MODIFIED in self._state_filter
+    self._do_incomparable = DiffStat.INCOMPARABLE in self._state_filter
+
+    self.dircmp = filecmp.dircmp(
+        self.old_dir, self.new_dir, ignore=self._ignores)
+    self._diff_stats = []
+    self._diff_stat_lines = []
+    self._diff_lines = []
+    self._processed_cnt = 0
+    self._common_dir_len = ChangeReport.get_common_path_len(
+        self.old_dir, self.new_dir)
+
+  @staticmethod
+  def get_common_path_len(dir1, dir2):
+    """Gets the length of the common path of old & new folders."""
+    sep = os.path.sep
+    last_sep_pos = 0
+    for i in range(len(dir1)):
+      if dir1[i] == sep:
+        last_sep_pos = i
+      if dir1[i] != dir2[i]:
+        break
+    return last_sep_pos + 1
+
+  @staticmethod
+  def get_diff_stat_header():
+    """Gets the diff statistic CSV header."""
+    return 'file,ext,text,state,{0},{1}\n'.format(
+        FileStat.get_csv_header('new'), FileStat.get_csv_header('old'))
+
+  def get_diff_stat_lines(self):
+    """Gets the diff statistic CSV lines."""
+    if self._processed_cnt < 1:
+      self._process_dircmp(self.dircmp)
+      self._processed_cnt += 1
+
+      self._diff_stat_lines = []
+      for diff_stat in self._diff_stats:
+        self._diff_stat_lines.append('{0},{1},{2},{3},{4},{5}\n'.format(
+            FileStat.no_comma(diff_stat.name), diff_stat.ext,
+            diff_stat.file_type, diff_stat.state,
+            diff_stat.new_file_stat.get_csv_str(self._common_dir_len),
+            diff_stat.old_file_stat.get_csv_str(self._common_dir_len)))
+
+    return self._diff_stat_lines
+
+  def get_diff_lines(self):
+    """Gets the diff output lines."""
+    if self._processed_cnt < 1:
+      self._process_dircmp(self.dircmp)
+      self._processed_cnt += 1
+    return self._diff_lines
+
+  def _process_dircmp(self, dircmp):
+    """Compare all files in a dircmp object for diff statstics & output."""
+    if self._do_moeified:
+      self._process_diff_files(dircmp)
+
+    for subdir_dircmp in dircmp.subdirs.values():
+      rp = pathlib.Path(subdir_dircmp.right)
+      lp = pathlib.Path(subdir_dircmp.left)
+      if rp.is_symlink() or lp.is_symlink():
+        print('SKIP: symlink: {0} or {1}'.format(subdir_dircmp.right,
+                                                 subdir_dircmp.left))
+        continue
+      self._process_dircmp(subdir_dircmp)
+
+    if self._do_new:
+      self._process_others(dircmp.right_only, dircmp.right,
+                           self._new_dir_prefix_len, DiffStat.NEW)
+    if self._do_same:
+      self._process_others(dircmp.same_files, dircmp.right,
+                           self._new_dir_prefix_len, DiffStat.SAME)
+    if self._do_incomparable:
+      self._process_others(dircmp.funny_files, dircmp.right,
+                           self._new_dir_prefix_len, DiffStat.INCOMPARABLE)
+    if self._do_removed:
+      self._process_others(dircmp.left_only, dircmp.left,
+                           self._old_dir_prefix_len, DiffStat.REMOVED)
+
+  def _process_others(self, files, adir, prefix_len, state):
+    """Processes files are not modified."""
+    empty_stat = FileStat(None)
+    for file in files:
+      file_path = pathlib.Path(adir, file)
+      if file_path.is_symlink():
+        print('SKIP: symlink: {0}, {1}'.format(state, file_path))
+        continue
+      elif file_path.is_dir():
+        flist = self._get_filtered_files(file_path)
+        self._process_others(flist, adir, prefix_len, state)
+      else:
+        file_stat = FileStat(file_path)
+        common_name = str(file_path)[prefix_len:]
+        if state == DiffStat.REMOVED:
+          diff_stat = DiffStat(common_name, file_stat, empty_stat, state)
+        else:
+          diff_stat = DiffStat(common_name, empty_stat, file_stat, state)
+        try:
+          with open(file_path, encoding='utf-8') as f:
+            lines = f.readlines()
+          file_stat.line_cnt = len(lines)
+          file_type = FileStat.TEXT
+        except UnicodeDecodeError:
+          file_type = FileStat.NON_TEXT
+
+        diff_stat.file_type = file_type
+        self._diff_stats.append(diff_stat)
+
+  def _process_diff_files(self, dircmp):
+    """Processes files are modified."""
+    for file in dircmp.diff_files:
+      old_file_path = pathlib.Path(dircmp.left, file)
+      new_file_path = pathlib.Path(dircmp.right, file)
+      self._diff_files(old_file_path, new_file_path)
+
+  def _diff_files(self, old_file_path, new_file_path):
+    """Diff old & new files."""
+    old_file_stat = FileStat(old_file_path)
+    new_file_stat = FileStat(new_file_path)
+    common_name = str(new_file_path)[self._new_dir_prefix_len:]
+    diff_stat = DiffStat(common_name, old_file_stat, new_file_stat,
+                         DiffStat.MODIFIED)
+
+    try:
+      with open(old_file_path, encoding='utf-8') as f1:
+        old_lines = f1.readlines()
+      old_file_stat.line_cnt = len(old_lines)
+      with open(new_file_path, encoding='utf-8') as f2:
+        new_lines = f2.readlines()
+      new_file_stat.line_cnt = len(new_lines)
+      diff_lines = list(
+          difflib.context_diff(old_lines, new_lines, old_file_path.name,
+                               new_file_path.name))
+      file_type = FileStat.TEXT
+      if diff_lines:
+        self._diff_lines.extend(diff_lines)
+        diff_stat.add_diff_stat(diff_lines)
+      else:
+        print('WARNING: no diff lines on {0} {1}'.format(
+            old_file_path, new_file_path))
+
+    except UnicodeDecodeError:
+      file_type = FileStat.NON_TEXT
+
+    diff_stat.file_type = file_type
+    self._diff_stats.append(diff_stat)
+
+  def _get_filtered_files(self, dir_path):
+    """Returns a filtered file list."""
+    flist = []
+    for f in dir_path.glob('*'):
+      if f.name not in self._ignores:
+        if f.is_symlink():
+          print('SKIP: symlink: %s' % f)
+          continue
+        else:
+          flist.append(f)
+    return flist
+
+
+def write_file(file, lines, header=None):
+  """Write lines into a file."""
+
+  with open(file, 'w') as f:
+    if header:
+      f.write(header)
+
+    f.writelines(lines)
+  print('OUTPUT: {0}, {1} lines'.format(file, len(lines)))
+
+
+def main():
+  parser = argparse.ArgumentParser(
+      'Generate a diff stat cvs file for 2 versions of a codebase')
+  parser.add_argument('--old_dir', help='the old version codebase dir')
+  parser.add_argument('--new_dir', help='the new version codebase dir')
+  parser.add_argument(
+      '--csv_file', required=False, help='the diff stat cvs file if to create')
+  parser.add_argument(
+      '--diff_output_file',
+      required=False,
+      help='the diff output file if to create')
+  parser.add_argument(
+      '--ignores',
+      required=False,
+      default='.repo,.git,.github,.idea,__MACOSX,.prebuilt_info',
+      help='names to ignore')
+  parser.add_argument(
+      '--state_filter',
+      required=False,
+      default='1,2,3',
+      help='csv diff states to process, 0:SAME, 1:NEW, 2:REMOVED, 3:MODIFIED, '
+      '4:INCOMPARABLE')
+
+  args = parser.parse_args()
+
+  if not os.path.isdir(args.old_dir):
+    print('ERROR: %s does not exist.' % args.old_dir)
+    exit()
+
+  if not os.path.isdir(args.new_dir):
+    print('ERROR: %s does not exist.' % args.new_dir)
+    exit()
+
+  change_report = ChangeReport(args.old_dir, args.new_dir, args.ignores,
+                               args.state_filter)
+  if args.csv_file:
+    write_file(
+        args.csv_file,
+        change_report.get_diff_stat_lines(),
+        header=ChangeReport.get_diff_stat_header())
+
+  if args.diff_output_file:
+    write_file(args.diff_output_file, change_report.get_diff_lines())
+
+
+if __name__ == '__main__':
+  main()
diff --git a/dev/change_report_test.py b/dev/change_report_test.py
new file mode 100644
index 0000000..20d6483
--- /dev/null
+++ b/dev/change_report_test.py
@@ -0,0 +1,104 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2021 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Tests for change_report.py."""
+
+import os
+import pathlib
+import unittest
+
+import change_report
+
+
+class ChangeReportTest(unittest.TestCase):
+  """Tests for ChangeReport."""
+
+  def setUp(self):
+    super().setUp()
+    old_dir = str(ChangeReportTest.get_resrouce_path('old_codebase'))
+    new_dir = str(ChangeReportTest.get_resrouce_path('new_codebase'))
+    self.change_report = change_report.ChangeReport(
+        old_dir, new_dir, state_filter='0,1,2,3,4')
+
+  def test_get_diff_stat_lines(self):
+    """Tests if the diff stat of new & old_codebase matches change_report-new_vs_old_codebase.csv."""
+    diff_stat_lines = []
+    diff_stat_lines.append(change_report.ChangeReport.get_diff_stat_header())
+    diff_stat_lines.extend(self.change_report.get_diff_stat_lines())
+
+    expected_diff_stat_lines = ChangeReportTest.get_expected_lines(
+        'change_report-new_vs_old_codebase.csv')
+
+    offending_line_indexes = ChangeReportTest.diff_lines(
+        expected_diff_stat_lines, diff_stat_lines)
+    self.assertEqual(len(offending_line_indexes), 0)
+
+  def test_get_diff_lines(self):
+    """Tests if the diff stat of new & old_codebase matches change_report_diff-new_vs_old_codebase.txt."""
+    diff_lines = self.change_report.get_diff_lines()
+
+    expected_diff_lines = ChangeReportTest.get_expected_lines(
+        'change_report_diff-new_vs_old_codebase.txt')
+
+    offending_line_indexes = ChangeReportTest.diff_lines(
+        expected_diff_lines, diff_lines)
+    self.assertEqual(len(offending_line_indexes), 0)
+
+  @staticmethod
+  def get_resrouce_path(target):
+    # .../dev/change_report_test.py
+    this_path = pathlib.Path(os.path.abspath(__file__)).parents[0]
+    return pathlib.Path(this_path, 'resource', target)
+
+  @staticmethod
+  def get_expected_lines(target):
+    file = ChangeReportTest.get_resrouce_path(target)
+    with open(file, 'r') as f:
+      lines = f.readlines()
+    return lines
+
+  @staticmethod
+  def diff_lines(expected, actual):
+    expected_len = len(expected)
+    actual_len = len(actual)
+    offending_line_indexes = []
+
+    if actual_len < expected_len:
+      l = actual_len
+    else:
+      l = expected_len
+
+    for i in range(l):
+      if expected[i] != actual[i]:
+        print('ERROR: line %d is not as expected' % i)
+        print(expected[i])
+        print(actual[i])
+        offending_line_indexes.append(i)
+
+    if actual_len < expected_len:
+      print('ERROR: Missing %d lines' % (expected_len - actual_len))
+      for j in range(actual_len, expected_len):
+        print(expected[j])
+        offending_line_indexes.append(j)
+    elif actual_len > expected_len:
+      print('ERROR: Extra %d lines' % (actual_len - expected_len))
+      for k in range(expected_len, actual_len):
+        print(actual[k])
+        offending_line_indexes.append(k)
+    return offending_line_indexes
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/dev/resource/change_report-new_vs_old_codebase.csv b/dev/resource/change_report-new_vs_old_codebase.csv
new file mode 100644
index 0000000..63e8e0b
--- /dev/null
+++ b/dev/resource/change_report-new_vs_old_codebase.csv
@@ -0,0 +1,18 @@
+file,ext,text,state,new_file,new_size,new_line,new_group,new_add,new_remove,new_replace,old_file,old_size,old_line,old_group,old_add,old_remove,old_replace
+modified_comma_.txt,txt,1,3,new_codebase/modified_comma_.txt,300,103,3,3,0,3,old_codebase/modified_comma_.txt,299,101,3,0,2,2
+modified.png,png,0,3,new_codebase/modified.png,4065,0,0,0,0,0,old_codebase/modified.png,4044,0,0,0,0,0
+modified.txt,txt,1,3,new_codebase/modified.txt,300,103,3,3,0,3,old_codebase/modified.txt,299,101,3,0,2,2
+modified_sub_dir/modified.txt,txt,1,3,new_codebase/modified_sub_dir/modified.txt,300,103,3,3,0,3,old_codebase/modified_sub_dir/modified.txt,299,101,3,0,2,2
+modified_sub_dir/sub_sub_dir/modified.txt,txt,1,3,new_codebase/modified_sub_dir/sub_sub_dir/modified.txt,300,103,3,3,0,3,old_codebase/modified_sub_dir/sub_sub_dir/modified.txt,299,101,3,0,2,2
+same_sub_dir/sub_sub_dir/same.txt,txt,1,0,new_codebase/same_sub_dir/sub_sub_dir/same.txt,21,10,0,0,0,0,,0,0,0,0,0,0
+same_sub_dir/same.txt,txt,1,0,new_codebase/same_sub_dir/same.txt,21,10,0,0,0,0,,0,0,0,0,0,0
+new.png,png,0,1,new_codebase/new.png,7384,0,0,0,0,0,,0,0,0,0,0,0
+new.txt,txt,1,1,new_codebase/new.txt,21,10,0,0,0,0,,0,0,0,0,0,0
+new_sub_dir/sub_sub_dir/new.txt,txt,1,1,new_codebase/new_sub_dir/sub_sub_dir/new.txt,21,10,0,0,0,0,,0,0,0,0,0,0
+new_sub_dir/new.txt,txt,1,1,new_codebase/new_sub_dir/new.txt,21,10,0,0,0,0,,0,0,0,0,0,0
+same.png,png,0,0,new_codebase/same.png,389,0,0,0,0,0,,0,0,0,0,0,0
+same.txt,txt,1,0,new_codebase/same.txt,21,10,0,0,0,0,,0,0,0,0,0,0
+removed.png,png,0,2,,0,0,0,0,0,0,old_codebase/removed.png,7384,0,0,0,0,0
+removed.txt,txt,1,2,,0,0,0,0,0,0,old_codebase/removed.txt,21,10,0,0,0,0
+removed_sub_dir/sub_sub_dir/removed.txt,txt,1,2,,0,0,0,0,0,0,old_codebase/removed_sub_dir/sub_sub_dir/removed.txt,21,10,0,0,0,0
+removed_sub_dir/removed.txt,txt,1,2,,0,0,0,0,0,0,old_codebase/removed_sub_dir/removed.txt,21,10,0,0,0,0
diff --git a/dev/resource/change_report_diff-new_vs_old_codebase.txt b/dev/resource/change_report_diff-new_vs_old_codebase.txt
new file mode 100644
index 0000000..31c9fa7
--- /dev/null
+++ b/dev/resource/change_report_diff-new_vs_old_codebase.txt
@@ -0,0 +1,144 @@
+*** modified,comma,.txt
+--- modified,comma,.txt
+***************
+*** 1,3 ****
+--- 1,6 ----
++ -2
++ -1
++ 0
+  1
+  2
+  3
+***************
+*** 39,46 ****
+  39
+  40
+  41
+- 42
+- 42
+  42
+  43
+  44
+--- 42,47 ----
+***************
+*** 97,101 ****
+  95
+  96
+  97
+! 98.0
+! 100.0
+--- 98,103 ----
+  95
+  96
+  97
+! 98
+! 99
+! 100
+*** modified.txt
+--- modified.txt
+***************
+*** 1,3 ****
+--- 1,6 ----
++ -2
++ -1
++ 0
+  1
+  2
+  3
+***************
+*** 39,46 ****
+  39
+  40
+  41
+- 42
+- 42
+  42
+  43
+  44
+--- 42,47 ----
+***************
+*** 97,101 ****
+  95
+  96
+  97
+! 98.0
+! 100.0
+--- 98,103 ----
+  95
+  96
+  97
+! 98
+! 99
+! 100
+*** modified.txt
+--- modified.txt
+***************
+*** 1,3 ****
+--- 1,6 ----
++ -2
++ -1
++ 0
+  1
+  2
+  3
+***************
+*** 39,46 ****
+  39
+  40
+  41
+- 42
+- 42
+  42
+  43
+  44
+--- 42,47 ----
+***************
+*** 97,101 ****
+  95
+  96
+  97
+! 98.0
+! 100.0
+--- 98,103 ----
+  95
+  96
+  97
+! 98
+! 99
+! 100
+*** modified.txt
+--- modified.txt
+***************
+*** 1,3 ****
+--- 1,6 ----
++ -2
++ -1
++ 0
+  1
+  2
+  3
+***************
+*** 39,46 ****
+  39
+  40
+  41
+- 42
+- 42
+  42
+  43
+  44
+--- 42,47 ----
+***************
+*** 97,101 ****
+  95
+  96
+  97
+! 98.0
+! 100.0
+--- 98,103 ----
+  95
+  96
+  97
+! 98
+! 99
+! 100
diff --git a/dev/resource/new_codebase/modified,comma,.txt b/dev/resource/new_codebase/modified,comma,.txt
new file mode 100644
index 0000000..a3e1e72
--- /dev/null
+++ b/dev/resource/new_codebase/modified,comma,.txt
@@ -0,0 +1,103 @@
+-2
+-1
+0
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
diff --git a/dev/resource/new_codebase/modified.png b/dev/resource/new_codebase/modified.png
new file mode 100644
index 0000000..6ca162a
--- /dev/null
+++ b/dev/resource/new_codebase/modified.png
Binary files differ
diff --git a/dev/resource/new_codebase/modified.txt b/dev/resource/new_codebase/modified.txt
new file mode 100644
index 0000000..a3e1e72
--- /dev/null
+++ b/dev/resource/new_codebase/modified.txt
@@ -0,0 +1,103 @@
+-2
+-1
+0
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
diff --git a/dev/resource/new_codebase/modified_sub_dir/modified.txt b/dev/resource/new_codebase/modified_sub_dir/modified.txt
new file mode 100644
index 0000000..a3e1e72
--- /dev/null
+++ b/dev/resource/new_codebase/modified_sub_dir/modified.txt
@@ -0,0 +1,103 @@
+-2
+-1
+0
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
diff --git a/dev/resource/new_codebase/modified_sub_dir/sub_sub_dir/modified.txt b/dev/resource/new_codebase/modified_sub_dir/sub_sub_dir/modified.txt
new file mode 100644
index 0000000..a3e1e72
--- /dev/null
+++ b/dev/resource/new_codebase/modified_sub_dir/sub_sub_dir/modified.txt
@@ -0,0 +1,103 @@
+-2
+-1
+0
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98
+99
+100
diff --git a/dev/resource/new_codebase/modified_sub_dir/sub_sub_dir/relative_symbolic_sub_dir b/dev/resource/new_codebase/modified_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
new file mode 120000
index 0000000..a96aa0e
--- /dev/null
+++ b/dev/resource/new_codebase/modified_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
@@ -0,0 +1 @@
+..
\ No newline at end of file
diff --git a/dev/resource/new_codebase/new.png b/dev/resource/new_codebase/new.png
new file mode 100644
index 0000000..82f73fe
--- /dev/null
+++ b/dev/resource/new_codebase/new.png
Binary files differ
diff --git a/dev/resource/new_codebase/new.txt b/dev/resource/new_codebase/new.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/new_codebase/new.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/new_codebase/new_sub_dir/new.txt b/dev/resource/new_codebase/new_sub_dir/new.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/new_codebase/new_sub_dir/new.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/new_codebase/new_sub_dir/sub_sub_dir/new.txt b/dev/resource/new_codebase/new_sub_dir/sub_sub_dir/new.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/new_codebase/new_sub_dir/sub_sub_dir/new.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/new_codebase/new_sub_dir/sub_sub_dir/relative_symbolic_sub_dir b/dev/resource/new_codebase/new_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
new file mode 120000
index 0000000..a96aa0e
--- /dev/null
+++ b/dev/resource/new_codebase/new_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
@@ -0,0 +1 @@
+..
\ No newline at end of file
diff --git a/dev/resource/new_codebase/same.png b/dev/resource/new_codebase/same.png
new file mode 100644
index 0000000..57585da
--- /dev/null
+++ b/dev/resource/new_codebase/same.png
Binary files differ
diff --git a/dev/resource/new_codebase/same.txt b/dev/resource/new_codebase/same.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/new_codebase/same.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/new_codebase/same_sub_dir/same.txt b/dev/resource/new_codebase/same_sub_dir/same.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/new_codebase/same_sub_dir/same.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/new_codebase/same_sub_dir/sub_sub_dir/relative_symbolic_sub_dir b/dev/resource/new_codebase/same_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
new file mode 120000
index 0000000..a96aa0e
--- /dev/null
+++ b/dev/resource/new_codebase/same_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
@@ -0,0 +1 @@
+..
\ No newline at end of file
diff --git a/dev/resource/new_codebase/same_sub_dir/sub_sub_dir/same.txt b/dev/resource/new_codebase/same_sub_dir/sub_sub_dir/same.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/new_codebase/same_sub_dir/sub_sub_dir/same.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/old_codebase/modified,comma,.txt b/dev/resource/old_codebase/modified,comma,.txt
new file mode 100644
index 0000000..471fe15
--- /dev/null
+++ b/dev/resource/old_codebase/modified,comma,.txt
@@ -0,0 +1,101 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+42
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98.0
+100.0
diff --git a/dev/resource/old_codebase/modified.png b/dev/resource/old_codebase/modified.png
new file mode 100644
index 0000000..67d0b06
--- /dev/null
+++ b/dev/resource/old_codebase/modified.png
Binary files differ
diff --git a/dev/resource/old_codebase/modified.txt b/dev/resource/old_codebase/modified.txt
new file mode 100644
index 0000000..471fe15
--- /dev/null
+++ b/dev/resource/old_codebase/modified.txt
@@ -0,0 +1,101 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+42
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98.0
+100.0
diff --git a/dev/resource/old_codebase/modified_sub_dir/modified.txt b/dev/resource/old_codebase/modified_sub_dir/modified.txt
new file mode 100644
index 0000000..471fe15
--- /dev/null
+++ b/dev/resource/old_codebase/modified_sub_dir/modified.txt
@@ -0,0 +1,101 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+42
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98.0
+100.0
diff --git a/dev/resource/old_codebase/modified_sub_dir/sub_sub_dir/modified.txt b/dev/resource/old_codebase/modified_sub_dir/sub_sub_dir/modified.txt
new file mode 100644
index 0000000..471fe15
--- /dev/null
+++ b/dev/resource/old_codebase/modified_sub_dir/sub_sub_dir/modified.txt
@@ -0,0 +1,101 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+42
+42
+43
+44
+45
+46
+47
+48
+49
+50
+51
+52
+53
+54
+55
+56
+57
+58
+59
+60
+61
+62
+63
+64
+65
+66
+67
+68
+69
+70
+71
+72
+73
+74
+75
+76
+77
+78
+79
+80
+81
+82
+83
+84
+85
+86
+87
+88
+89
+90
+91
+92
+93
+94
+95
+96
+97
+98.0
+100.0
diff --git a/dev/resource/old_codebase/modified_sub_dir/sub_sub_dir/relative_symbolic_sub_dir b/dev/resource/old_codebase/modified_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
new file mode 120000
index 0000000..a96aa0e
--- /dev/null
+++ b/dev/resource/old_codebase/modified_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
@@ -0,0 +1 @@
+..
\ No newline at end of file
diff --git a/dev/resource/old_codebase/removed.png b/dev/resource/old_codebase/removed.png
new file mode 100644
index 0000000..82f73fe
--- /dev/null
+++ b/dev/resource/old_codebase/removed.png
Binary files differ
diff --git a/dev/resource/old_codebase/removed.txt b/dev/resource/old_codebase/removed.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/old_codebase/removed.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/old_codebase/removed_sub_dir/removed.txt b/dev/resource/old_codebase/removed_sub_dir/removed.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/old_codebase/removed_sub_dir/removed.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/old_codebase/removed_sub_dir/sub_sub_dir/relative_symbolic_sub_dir b/dev/resource/old_codebase/removed_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
new file mode 120000
index 0000000..a96aa0e
--- /dev/null
+++ b/dev/resource/old_codebase/removed_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
@@ -0,0 +1 @@
+..
\ No newline at end of file
diff --git a/dev/resource/old_codebase/removed_sub_dir/sub_sub_dir/removed.txt b/dev/resource/old_codebase/removed_sub_dir/sub_sub_dir/removed.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/old_codebase/removed_sub_dir/sub_sub_dir/removed.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/old_codebase/same.png b/dev/resource/old_codebase/same.png
new file mode 100644
index 0000000..57585da
--- /dev/null
+++ b/dev/resource/old_codebase/same.png
Binary files differ
diff --git a/dev/resource/old_codebase/same.txt b/dev/resource/old_codebase/same.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/old_codebase/same.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/old_codebase/same_sub_dir/same.txt b/dev/resource/old_codebase/same_sub_dir/same.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/old_codebase/same_sub_dir/same.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10
diff --git a/dev/resource/old_codebase/same_sub_dir/sub_sub_dir/relative_symbolic_sub_dir b/dev/resource/old_codebase/same_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
new file mode 120000
index 0000000..a96aa0e
--- /dev/null
+++ b/dev/resource/old_codebase/same_sub_dir/sub_sub_dir/relative_symbolic_sub_dir
@@ -0,0 +1 @@
+..
\ No newline at end of file
diff --git a/dev/resource/old_codebase/same_sub_dir/sub_sub_dir/same.txt b/dev/resource/old_codebase/same_sub_dir/sub_sub_dir/same.txt
new file mode 100644
index 0000000..f00c965
--- /dev/null
+++ b/dev/resource/old_codebase/same_sub_dir/sub_sub_dir/same.txt
@@ -0,0 +1,10 @@
+1
+2
+3
+4
+5
+6
+7
+8
+9
+10