# Version 2.1.5 # tag::includeManual[] """ CaseCheckTrigger.py Example 1: Typical usage from the Helix Core server triggers table: Triggers: CheckCase change-submit //... "/usr/bin/python3 /p4/common/bin/triggers/CheckCaseTrigger.py %changelist% myuser=%user%" SAMPLE OUTPUT: Submit validation failed -- fix problems then use 'p4 submit -c 1234'. 'CheckCaseTrigger' validation failed: Your submission has been rejected because the following files are inconsistent in their use of case with respect to existing directories: Your file: '//depot/dir/test' existing file/dir: '//depot/DIR' BYPASS LOGIC: By default, this trigger can be bypassed by any user by adding the token BYPASS_CASE_CHECK to the changelist description. Specify 'allowbypass=no' on the command line to disable the ability to bypass this trigger As an exception, if the user is 'git-fusion-user', the case check is always bypassed if 'myuser' is defined. DEPENDENCIES: This trigger requires P4Triggers.py and the P4Python API. SEE ALSO: See 'p4 help triggers'. """ # end::includeManual[] from __future__ import print_function import P4 import P4Triggers import os import re import subprocess import sys class CheckCaseTrigger(P4Triggers.P4Trigger): # tag::includeManual[] """CaseCheckTrigger is a subclass of P4Trigger. Use this trigger to ensure that your depot does not contain two filenames or directories that only differ in case. Having files with different case spelling will cause problems in mixed environments where both case-insensitive clients like Windows and case- sensitive clients like UNIX access the same server. """ # end::includeManual[] def __init__(self, *args, maxErrors=10, **kwargs): kwargs['charset'] = 'none' kwargs['api_level'] = 71 self.allowBypass=AllowBypass fileFilter = None if 'filefilter' in kwargs: fileFilter = kwargs['filefilter'] del kwargs['filefilter'] P4Triggers.P4Trigger.__init__(self, **kwargs) self.parse_args(__doc__, args) # Ensure that -ztag global option is used. self.p4.tagged = True # need to reset the args in case a p4config file overwrote them for (k, v) in kwargs.items(): if k != "log": try: setattr(self.p4, k, v) except: self.logger.error("error setting p4 property: '%s' to '%v'" % (k, v)) self.map = None if fileFilter: try: with open(fileFilter) as f: self.map = P4.Map() for line in f: self.map.insert(line.strip()) except IOError: self.logger.error("Could not open filter file %s" % fileFilter) self.maxErrors = maxErrors self.depotCache = {} self.masterCache = {} self.maxWildcards = 9 def add_parse_args(self, parser): """Specific args for this trigger - also calls super class to add common trigger args""" parser.add_argument('change', help="Change to validate - %%change%% argument from triggers entry.") super(CheckCaseTrigger, self).add_parse_args(parser) def setUp(self): info = self.p4.run_info()[0] if "unicode" in info and info["unicode"] == "enabled": self.p4.charset = "utf8" self.p4.exception_level = 1 # ignore WARNINGS like "no such file" self.p4.prog = "CheckCaseTrigger" if self.allowBypass: self.USER_MESSAGE=""" Your changelist submit attempt has been rejected because one or more file paths opened for add vary only by case from existing files/directory paths. Creating file/folders that vary only in case from existing paths causes inconsistent behavior across platforms with different case handling behaviors (e.g. Windows, Linux/UNIX, Mac OSX). Thus, adding case-only variations of existing paths is strongly discouraged. If you are certain the files to be added will only be accessed from workspaces on case-sensitive platforms (like UNIX/Linux), then this trigger can be bypassed by adding the token BYPASS_CASE_CHECK to the changelist description and attempting the submit again. Alternately, you can revert any files opened for add in your changelists that vary only in case from existing files, or move them to new names that don't conflict with existing files. Offending files: """ else: self.USER_MESSAGE=""" Your changelist submit attempt has been rejected because one or more file paths opened for add vary only by case from existing files/directory paths. Creating file/folders that vary only in case from existing paths causes inconsistent behavior across platforms with different case handling behaviors (e.g. Windows, Linux/UNIX, Mac OSX). Thus, adding case-only variations of existing paths is disallowed. To move forward, you can revert any files opened for add in your changelists that vary only in case from existing files, or move them to new names that don't conflict with existing files. Offending files: """ self.BADFILE_FORMAT=""" Your file: '%s' existing file/dir: '%s' """ def validate(self): """Here the fun begins. This method overrides P4Trigger.validate()""" badlist = {} files = self.change.files #self.logger.debug("validate: Files to submit: %s", files) self.filterRenames(files) # Determine valid file list and depots to cache, # including the max directory depth for each cache depth = 0 validFiles = [] uniqueDepots = {} for file in files: action = file.revisions[0].action if self.map and self.map.includes(file.depotFile): continue if action not in ("add", "branch", "move/add"): continue mismatch = "" path = file.depotFile[2:] #self.logger.debug("validate: path = %s", path) parts = path.split('/') #self.logger.debug("validate: parts = %s", parts) depot = parts[0] #Determine if we have a valid depot. mismatch = self.findDepotMismatch(depot) if mismatch: badlist[file.depotFile] = mismatch continue if depot in uniqueDepots : if len(parts) > uniqueDepots[depot] : uniqueDepots[depot] = len(parts) else : uniqueDepots[depot] = len(parts) validFiles.append(file.depotFile) #self.logger.debug("validate: file.depotFile = %s", file.depotFile) #self.logger.debug("validate: uniqueDepots = %s", uniqueDepots) #self.logger.debug("validate: validFiles = %s",validFiles) # Build cache for each unique depot. self.buildCache(uniqueDepots, validFiles) # Look for files in cache. This includes looking for directories in file path. self.searchCache(validFiles, badlist) #self.logger.debug("validate: badlist = %s", badlist) #self.logger.debug("validate: masterCache = %s", self.masterCache) if len(badlist) > 0: self.report(badlist) return len(badlist) == 0 #end of validate # This method builds a global cache to use for mismatch searches. # IN: depots to use, including depth of cache for each depot # OUT: None def buildCache(self, depots, fileList): #self.logger.debug("buildCache: %s", depots) for u in depots : # Add dirs to specified level of the depot. # NOTE: There is a complication, because the 'dirs' commmand # rejects more than self.maxWildcards wildcards in a row. # Hence the complex algorithm. dirList = [] #Subtract depot level depotDepth = depots[u] - 1 loops = depotDepth // self.maxWildcards if depotDepth % self.maxWildcards > 0: loops += 1 #self.logger.debug("buildCache: loops = %d", loops) for i in range(loops): # Optimization: If first pass through loop didn't # result in any directories being returned, then # we can break without any further looping. if (i > 0) and (not dirList): break if (depotDepth >= self.maxWildcards): levels = self.maxWildcards depotDepth -= self.maxWildcards else: levels = depotDepth self.getDirs( u, levels, dirList) # Build cache for d in dirList: cd = self.canonical(d) if not cd in self.masterCache : self.masterCache[cd] = d #end for loops # Add files #Optimize by looking for files from shared root in current depot. commonList = [] #searchStr = '^' + '//' + u for f in fileList: if f.find('//' + u) != -1: commonList.append(f) if len(commonList) == 1: fileStr = '//' + u + '/...' else: fileStr = '/' + os.path.commonpath(commonList) + '/...' #self.logger.debug("buildCache: fileStr = %s", fileStr) for f in self.p4.run_files( fileStr) : #self.logger.debug("validate: f = %s", f) if "delete" not in f["action"] : f = f["depotFile"] cf = self.canonical(f) if not cf in self.masterCache : self.masterCache[cf] = f #end buildCache # Method canonical # IN: string, utf8 compatible # OUT: unicode string, all lower case def canonical(self, aString): return aString.lower() # Method filterRenames: # Removes pairs of files that only differ in case # where one action is branch, and the other delete def filterRenames(self, files): branches = [x for x in files if x.revisions[0].action == 'branch'] deletes = [x.depotFile.lower() for x in files if x.revisions[0].action == 'delete'] for f in branches: if f.depotFile.lower() in deletes: files.remove(f) # This method returns either a depot or directory that differs from # the supplied path only in case, or None if no mismatch is found # This method looks for a depot mismatch # It caches the depots as it found them (Global cache) # IN: depot name (as provided by the change list # OUT: None if (no mismatch found) else (stored depot name) def findDepotMismatch(self, depot): canonicalDepot = self.canonical(depot) # if the depot is in the cache, the cached version is authoritative if canonicalDepot in self.depotCache: if self.depotCache[canonicalDepot] == depot: return None else: return '//' + self.depotCache[canonicalDepot] # depot not found in cache. Populate the cache mismatch = None for d in self.p4.run_depots(): dname = d["name"] cd = self.canonical(dname) self.depotCache[cd] = dname if canonicalDepot == cd and depot != dname: mismatch = '//' + dname return mismatch # end findDepotMismatch # This method makes one call to p4 dirs using a parameter-specified number # of wildcards, guaranteed to be no more than self.maxWildcards. # IN: depot to use; number of wildcards to use; list of directories return by p4 dirs calls # OUT: dirList may be modified. def getDirs(self, depot, depth, dirList): #self.logger.debug("getDirs: depot = %s, depth = %d, dirList = %s", depot, depth, dirList) #self.logger.debug("getDirs: depot = %s, depth = %d", depot, depth) dirStr = '' for i in range(depth) : dirStr += '/*' if not dirList: # If nothing is in dirList then we just fill it. cacheStr = '//' + depot + dirStr #self.logger.debug("getDirs 1: cacheStr = %s", cacheStr) for d in self.p4.run_dirs(cacheStr) : #self.logger.debug("getDirs 1: d = %s", d) dirList.append(d["dir"]) else: # We have to look for entries in dirList that require another call to p4 dirs. p4dirStr = '' for dl in dirList: #self.logger.debug("getDirs: dl = %s", dl) # Make sure we're looking at the right depot. if dl.find("//" + depot) != -1: # If depth of dirList entry is a multiple of maximum wildcards # then do another search. levels = dl.count('/') - 2 # Subtract for // in front of depot if levels % self.maxWildcards == 0: cacheStr = dl + dirStr p4dirStr += cacheStr + ' ' #self.logger.debug("getDirs 2: cacheStr = %s", cacheStr) #for d in self.p4.run_dirs(cacheStr): #self.logger.debug("getDirs2: d = %s", d) #dirList.append(d["dir"]) #self.logger.debug("getDirs2: p4dirStr = %s", p4dirStr) # NOTE: For a batch p4 dirs call, I need to turn off error reporting. # Otherwise, the command fails if any "no such file(s)" errors are returned. # Turn it back on afterwards. self.p4.exception_level = 0 for d in self.p4.run_dirs(p4dirStr): #self.logger.debug("getDirs2: d = %s", d) dirList.append(d["dir"]) self.p4.exception_level = 2 #self.logger.debug("getDirs: dirList = %s", dirList) #end getDirs def report(self, badfiles): msg = self.USER_MESSAGE for (n, (file, mismatch)) in enumerate(badfiles.items()): if n >= self.maxErrors: break msg += self.BADFILE_FORMAT % (file, mismatch) self.message(msg) def run(self): """Runs trigger""" try: self.logger.debug("CheckCaseTrigger firing") self.setupP4() return self.parseChange(self.options.change) except Exception: return self.reportException() return 0 # This method searches a global cache to find case mismatches for changelist files. # File subdirectories and file itself are added to cache if no mismatches are found. # IN: List of changelist files for which we want verify there are no case mismatches. # Mismatch dictionary that records mismatches. # OUT: May modifiy mismatches parameter def searchCache(self, cfiles, mismatches): #self.logger.debug("searchCache: changelist files = %s, mismatches = %s", cfiles, mismatches) for f in cfiles : #self.logger.debug("searchCache: f = %s", f) mismatch = "" # Look for file and continue if it's in the cache. It's already been added. cf = self.canonical(f) if cf in self.masterCache : #self.logger.debug("searchCache: found %s in %s", f, self.masterCache) if f != self.masterCache[cf] : mismatch = self.masterCache[cf] mismatches[f] = mismatch continue # Look for substrings of file. If no matches, add. # Remove '//' to split. fpath = f[2:] fparts = fpath.split('/') #Add '//' back for searches fparts[0] = '//' + fparts[0] #self.logger.debug("searchCache: fparts = %s, len(fparts) = %d", fparts, len(fparts)) # Look for sub-directories. Add them to cache if not present. for i in range(1, len(fparts)) : mismatch = "" subParts = fparts[0 : i] subName = '/'.join(subParts) #self.logger.debug("searchCache: i = %d, subname = %s", i, subName) subcf = self.canonical(subName) # Break if we find a mismatch. Otherwise, add subName to cache. if subcf in self.masterCache : if self.masterCache[subcf] != subName : mismatch = self.masterCache[subcf] break else : self.masterCache[subcf] = subName # If no mismatch, add the file. if mismatch : mismatches[f] = mismatch else : self.masterCache[cf] = f #end searchCache # If called from the command line, go in here if __name__ == "__main__": # Generate new args - parsing out port=123 style way of specifying # parameters intended for p4 properties kwargs = {} args = [] for arg in sys.argv[1:]: p = arg.split("=", 1) if len(p) == 1: args.append(arg) else: kwargs[p[0]] = p[1] # Example of how to exclude the 'git-fusion-user' # Note: Need to remove 'myuser' after test as it's not a valid P4 argument. if 'myuser' in kwargs: if kwargs['myuser'] == 'git-fusion-user': sys.exit(0) else: del kwargs['myuser'] AllowBypass = 1 if 'allowbypass' in kwargs: if kwargs['allowbypass'] == 'no': AllowBypass = 0 # Remove 'allowbypass' after test as it's not a valid P4 argument. del kwargs['allowbypass'] if AllowBypass: # Grab the changelist description, and scan for the bypass token string. # If the token is detected, silently and immediately exit with a happy 0 # exit code. changelist = sys.argv[1] cmd = "%s -ztag -F %%desc%% describe -f -s %s" % (os.getenv('P4BIN','p4'), changelist) p = subprocess.Popen(cmd, stdout=subprocess.PIPE, shell=True) (changeDesc, err) = p.communicate() p_status = p.wait() # If the changelist description contains the text BYPASS_CASE_CHECK, # bypass the case check logic. if (re.search (b'BYPASS_CASE_CHECK', changeDesc, re.MULTILINE)): sys.exit(0) ct = CheckCaseTrigger(*args, maxErrors=10, **kwargs) sys.exit(ct.run())