This tool is based on the “iOS coverage detection principle and incremental code test coverage tool implementation” a practice (intrusion deletion), this article pays more attention to the implementation details, the principle part can refer to the original text.

The final effect is to modify the push script:

echo '-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -'
rate=$(cd $(dirname $PWD)/RCodeCoverage/ && python coverage.py  $proejctName | grep "RCoverageRate:" | sed 's/RCoverageRate:\([0-9-]*\).*/\1/g')
if [ $rate -eq1];then
	echo 'No coverage information, skip... '
elif[$(echo "$rate < 80.0" | bc) = 1 ];then
	echo 'Code coverage is'$rate', not meeting demand '
	echo '-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -'
  exit 1
else
	echo 'Code coverage is'$rate', about to upload code '
fi
echo '-- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -'
Copy the code

Next to each commit-msg is the code coverage information for the current commit:

Making links: xuezhulian/Coverage

The following describes the implementation of the tool in terms of increments and coverage.

The incremental

The incremental results are obtained according to Git.

Git status gets how many commits are currently required.

  aheadCommitRe = re.compile('Your branch is ahead of \'. * \' by ([0-9]*) commit')
  aheadCommitNum = None
  for line in os.popen('git status').xreadlines():
    result = aheadCommitRe.findall(line)
    if result:
      aheadCommitNum = result[0]
      break
Copy the code

Git rev-parse can get the commit id and git log diff if there is an uncommitted git commit.

  if aheadCommitNum:
    for i in range(0,int(aheadCommitNum)):
      commitid = os.popen('git rev-parse HEAD~%s'%i).read().strip()
      pushdiff.commitdiffs.append(CommitDiff(commitid))
    stashName = 'git-diff-stash'
    os.system('git stash save \'%s\'; git log -%s -v -U0> "%s/diff"'%(stashName,aheadCommitNum,SCRIPT_DIR))
    if string.find(os.popen('git stash list').readline(),stashName) ! = -1: os.system('git stash pop')
  else:
    #prevent change last commit msg without new commit 
    print 'No new commit'
    exit(1)
Copy the code

Classes and rows modified based on diff matches are only considered newly added, not deleted.

  commitidRe = re.compile('commit (\w{40})')
  classRe = re.compile('\+\+\+ b(.*)')
  changedLineRe = re.compile('\+(\d+),*(\d*) \@\@')

  commitdiff = None
  classdiff = None

  for line in diffFile.xreadlines():
    #match commit id
    commmidResult = commitidRe.findall(line)
    if commmidResult:
      commitid = commmidResult[0].strip()
      if pushdiff.contains_commitdiff(commitid):
        commitdiff = pushdiff.commitdiff(commitid)
      else:
        #TODO filter merge
        commitdiff = None

    if not commitdiff:
      continue

    #match class name
    classResult = classRe.findall(line)
    if classResult:
      classname = classResult[0].strip().split('/')[-1]
      classdiff = commitdiff.classdiff(classname)

    if not classdiff:
      continue

    #match lines
    lineResult = changedLineRe.findall(line)
    if lineResult:
      (startIndex,lines) = lineResult[0] 
      # add nothing
      if cmp(lines,'0') == 0:
        pass        
      #add startIndex line
      elif cmp(lines,' ') == 0:
        classdiff.changedlines.add(int(startIndex))
      #add lines from startindex
      else:
        for num in range(0,int(lines)):
          classdiff.changedlines.add(int(startIndex) + num)
Copy the code

Now you know how many commits need to be committed for each push, which files are modified for each commit, and the corresponding rows. You get the incremental part.

coverage

Coverage information was obtained by analyzing gCNO and GCDA files using lCOV tools. These two files are described in detail in the original text and will not be repeated here.

The first thing we need to do is determine the path of GCNO and GCDA. Xcode ->build Phases ->run script Add script exportenV. sh Export environment variables.

//exportenv.sh
scripts="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" export | egrep '( BUILT_PRODUCTS_DIR)|(CURRENT_ARCH)|(OBJECT_FILE_DIR_normal)|(SRCROOT)|(OBJROOT)|(TARGET_DEVICE_IDENTIFIER)|(TARGET_DEVIC E_MODEL)|(PRODUCT_BUNDLE_IDENTIFIER)' > "${scripts}/env.sh"

Copy the code
  • SCRIPT_DIR :/Users/yuencong/Desktop/coverage/RCodeCoverage
  • SRCROOT :/Users/yuencong/Desktop/coverage/Example
  • OBJROOT :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Intermediates.noindex
  • OBJECT_FILE_DIR_normal:/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/In termediates.noindex/Example.build/Debug-iphonesimulator/Example.build/Objects-normal
  • PRODUCT_BUNDLE_ID :coverage.Example
  • TARGET_DEVICE_ID :E87EED9C-5536-486A-BAB4-F9F7C6ED6287
  • BUILT_PRODUCTS_DIR :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Products/Debug-iphonesim ulator
  • GCDA_DIR :/Users/yuencong/Library/Developer/CoreSimulator/Devices/E87EED9C-5536-486A-BAB4-F9F7C6ED6287/data/Containers/Data/Appli cation//C4B45B67-5138-4636-8A8F-D042A06E7229/Documents/gcda_files
  • GCNO_DIR :/Users/yuencong/Library/Developer/Xcode/DerivedData/Example-cklwcecqxxdjbmftcefbxcsikfub/Build/Intermediates.noindex/Ex ample.build/Debug-iphonesimulator/Example.build/Objects-normal/x86_64

The path to GCNO_DIR is OBJECT_FILE_DIR_normal+arch. We only collect information in the simulator, so arch is x86_64. At present, the overall architecture of our APP is modular, and each module corresponds to a target, which is managed by Cocoapods. The normal path for each target is different. If we want a gcno file in the pod directory, we take the local pod repository path as an argument and change the normal path based on the podSpec file.

def handlepoddir():
  global OBJECT_FILE_DIR_normal
  global SRCROOT

  #default main repo  
  iflen(sys.argv) ! = 2:return
  #filter coverage dir
  if sys.argv[1] == SCRIPT_DIR.split('/') [1] :return
  repodir = sys.argv[1]
  SRCROOT = SCRIPT_DIR.replace(SCRIPT_DIR.split('/')[-1],repodir.strip())
  os.environ['SRCROOT'] = SRCROOT
  podspec = None
  for podspecPath in os.popen('find %s -name \"*.podspec\" -maxdepth 1' %SRCROOT).xreadlines():
    podspec = podspecPath.strip()
    break

  if podspec and os.path.exists(podspec):
    podspecFile = open(podspec,'r')
    snameRe = re.compile('s.name\s*=\s*[\"|\']([\w-]*)[\"|\']') for line in podspecFile.xreadlines(): snameResult = snameRe.findall(line) if snameResult: break sname = snameResult[0].strip() OBJECT_FILE_DIR_normal = OBJROOT + '/Pods.build/%s/%s.build/Objects-normal'%(BUILT_PRODUCTS_DIR,sname) if not os.path.exists(OBJECT_FILE_DIR_normal): print 'Error:\nOBJECT_FILE_DIR_normal:%s invalid path'%OBJECT_FILE_DIR_normal exit(1) os.environ['OBJECT_FILE_DIR_normal'] = OBJECT_FILE_DIR_normalCopy the code

Gcda files are stored in the emulator. The path to the current emulator can be identified with TARGET_DEVICE_ID. Under the folder corresponding to each APP in this path, there is a PList file that records the bundleID of the APP, and the APP is matched according to this BundleID. Then spell out the path to the GCDA file.

def gcdadir():
  GCDA_DIR = None
  USER_ROOT = os.environ['HOME'].strip()
  APPLICATIONS_DIR = '%s/Library/Developer/CoreSimulator/Devices/%s/data/Containers/Data/Application/' %(USER_ROOT,TARGET_DEVICE_ID)
  if not os.path.exists(APPLICATIONS_DIR):
    print 'Error:\nAPPLICATIONS_DIR:%s invaild file path'%APPLICATIONS_DIR
    exit(1)
  APPLICATION_ID_RE = re.compile('\w{8}-\w{4}-\w{4}-\w{4}-\w{12}')
  for file in os.listdir(APPLICATIONS_DIR):
    if not APPLICATION_ID_RE.findall(file):
      continue
    plistPath = APPLICATIONS_DIR + file.strip() + '/.com.apple.mobile_container_manager.metadata.plist'
    if not os.path.exists(plistPath):
      continue
    plistFile = open(plistPath,'r')
    plistContent = plistFile.read()
    plistFile.close()
    ifstring.find(plistContent,PRODUCT_BUNDLE_ID) ! = -1: GCDA_DIR = APPLICATIONS_DIR + file +'/Documents/gcda_files'
      break
  if not GCDA_DIR:
    print 'GCDA DIR invalid,please check xcode config'
    exit(1)
  if not os.path.exists(GCDA_DIR):
    print 'GCDA_DIR:%s path invalid'%GCDA_DIR
    exit(1)
  os.environ['GCDA_DIR'] = GCDA_DIR
  print("GCDA_DIR :"+GCDA_DIR)
Copy the code

After determining the gCNO and GCDA directory paths. Copy the gCNO and gCda files corresponding to the modified files obtained by git analysis to the source folder in the script directory.

  sourcespath = SCRIPT_DIR + '/sources'
  if os.path.isdir(sourcespath):
    shutil.rmtree(sourcespath)
  os.makedirs(sourcespath)

  for filename in changedfiles:
    gcdafile = GCDA_DIR+'/'+filename+'.gcda'
    if os.path.exists(gcdafile):
      shutil.copy(gcdafile,sourcespath)
    else:
      print 'Error:GCDA file not found for %s' %gcdafile
      exit(1)
    gcnofile = GCNO_DIR + '/'+filename + '.gcno'
    if not os.path.exists(gcnofile):
      gcnofile = gcnofile.replace(OBJECT_FILE_DIR_normal,OBJECT_FILE_DIR_main)
      if not os.path.exists(gcnofile):
        print 'Error:GCNO file not found for %s' %gcnofile
        exit(1)
    shutil.copy(gcnofile,sourcespath)
Copy the code

Next, we used the LCOV tool, which allows us to visualize code coverage and easily see which lines of files are not executed in the event of substandard coverage. The lcov command creates an intermediate file. Info based on gcno and gcda. The.info file records the functions contained in the file, the functions executed, the lines contained in the file, and the lines executed.

This is the key field we use to analyze coverage.

  • SF: <absolute path to the source file>
  • FN: <line number of function start>,<function name>
  • FNDA:<execution count>,<function name>
  • FNF:<number of functions found>
  • FNH:<number of function hit>
  • DA:<line number>,<execution count>[,<checksum>]
  • LH:<number of lines with a non-zero execution count>
  • LF:<number of instrumented lines>

The process of generating.info

  os.system(lcov + '-c -b %s -d %s -o \"Coverage.info\"' %(SCRIPT_DIR,sourcespath))
  if not os.path.exists(SCRIPT_DIR+'/Coverage.info') :print 'Error:failed to generate Coverage.info'
    exit(1)

  if os.path.getsize(SCRIPT_DIR+'/Coverage.info') = = 0:print 'Error:Coveragte.info size is 0'
    os.remove(SCRIPT_DIR+'/Coverage.info')
    exit(1)
Copy the code

Next, modify the.info file with git information to implement incremental changes. First, delete the classes that Git does not record changes.

  for line in os.popen(lcov + ' -l Coverage.info').xreadlines():
    result = headerFileRe.findall(line)
    if result and not result[0].strip() in changedClasses:
      filterClasses.add(result[0].strip())
  iflen(filterClasses) ! = 0: os.system(lcov +'--remove Coverage.info *%s* -o Coverage.info' %'* *'.join(filterClasses))
Copy the code

Delete lines that Git does not record changes to

    for line in lines:
    #match file name
    if line.startswith('SF:'):
      infoFilew.write('end_of_record\n')
      classname = line.strip().split('/')[-1].strip()
      changedlines = pushdiff.changedLinesForClass(classname)
      if len(changedlines) == 0:
        lcovclassinfo = None
      else:
        lcovclassinfo = lcovInfo.lcovclassinfo(classname)
        infoFilew.write(line)

    if not lcovclassinfo:
      continue
    #match lines
    DAResult = DARe.findall(line)
    if DAResult:
      (startIndex,count) = DAResult[0]
      if not int(startIndex) in changedlines:
        continue
      infoFilew.write(line)
      if int(count) == 0:
        lcovclassinfo.nohitlines.add(int(startIndex))
      else:
        lcovclassinfo.hitlines.add(int(startIndex))
      continue
Copy the code

Now, the.info file only records the coverage information of the classes and rows that Git has changed, while the LcovInfo data structure holds the relevant information, which will be used later in analyzing the coverage of each commit. Generate visual coverage information from the ·genhtml““ command. The result is saved in the coverage path in the script directory, and you can open index.html to see incremental coverage.

  if not os.path.getsize('Coverage.info') == 0:
    os.system(genhtml + 'Coverage.info -o Coverage')
  os.remove('Coverage.info')
Copy the code

Index.html example, secondary pages will have more details:

The last step is to modify commit-msg with git rebase to look like the opening.

  for i in reversed(range(0,len(pushdiff.commitdiffs))):
    commitdiff = pushdiff.commitdiffs[i]
    if not commitdiff:
      os.system('git rebase --abort')
      continue

    coveragerate = commitdiff.coveragerate()
    lines = os.popen('git log -1 --pretty=%B').readlines()

    commitMsg = lines[0].strip()
    commitMsgRe = re.compile('coverage: ([0-9\.-]*)')
    result = commitMsgRe.findall(commitMsg)
    if result:
      if result[0].strip() == '%.2f'%coveragerate:
        os.system('git rebase --continue')
        continue
      commitMsg = commitMsg.replace('coverage: %s'%result[0],'coverage: %.2f'%coveragerate)
    else:
      commitMsg = commitMsg + ' coverage: %.2f%%'%coveragerate
    lines[0] = commitMsg+'\n'
  
    stashName = 'commit-amend-stash'
    os.system('git stash save \'%s\'; git commit --amend -m \'%s \' --no-edit; ' %(stashName,' '.join(lines)))
    if string.find(os.popen('cd %s; git stash list'%SRCROOT).readline(),stashName) ! = -1: os.system('git stash pop')
    
    os.system('git rebase --continue; ')
Copy the code