git-p4: rewrite view handling

The old code was not very complete or robust. Redo it. This new code should be useful for a few possible additions in the future: - support for * and %%n wildcards - allowing ... inside paths - representing branch specs (not just client specs) - tracking changes to views Mark the remaining 12 tests in t9809 as fixed. Signed-off-by: Pete Wyckoff <pw@padd.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Pete Wyckoff committed Jan 2, 2012 at 18:05 UTC ecb7cf98a7d02e943ce44b083639c75011f3d8ec
2 files changed +258 -101
contrib/fast-import/git-p4
+246 -89
@@ -1169,6 +1169,218 @@ class P4Submit(Command, P4UserMap):
1169
1170 return True
1171
1172 +class View(object):
1173 + """Represent a p4 view ("p4 help views"), and map files in a
1174 + repo according to the view."""
1175 +
1176 + class Path(object):
1177 + """A depot or client path, possibly containing wildcards.
1178 + The only one supported is ... at the end, currently.
1179 + Initialize with the full path, with //depot or //client."""
1180 +
1181 + def __init__(self, path, is_depot):
1182 + self.path = path
1183 + self.is_depot = is_depot
1184 + self.find_wildcards()
1185 + # remember the prefix bit, useful for relative mappings
1186 + m = re.match("(//[^/]+/)", self.path)
1187 + if not m:
1188 + die("Path %s does not start with //prefix/" % self.path)
1189 + prefix = m.group(1)
1190 + if not self.is_depot:
1191 + # strip //client/ on client paths
1192 + self.path = self.path[len(prefix):]
1193 +
1194 + def find_wildcards(self):
1195 + """Make sure wildcards are valid, and set up internal
1196 + variables."""
1197 +
1198 + self.ends_triple_dot = False
1199 + # There are three wildcards allowed in p4 views
1200 + # (see "p4 help views"). This code knows how to
1201 + # handle "..." (only at the end), but cannot deal with
1202 + # "%%n" or "*". Only check the depot_side, as p4 should
1203 + # validate that the client_side matches too.
1204 + if re.search(r'%%[1-9]', self.path):
1205 + die("Can't handle %%n wildcards in view: %s" % self.path)
1206 + if self.path.find("*") >= 0:
1207 + die("Can't handle * wildcards in view: %s" % self.path)
1208 + triple_dot_index = self.path.find("...")
1209 + if triple_dot_index >= 0:
1210 + if not self.path.endswith("..."):
1211 + die("Can handle ... wildcard only at end of path: %s" %
1212 + self.path)
1213 + self.ends_triple_dot = True
1214 +
1215 + def ensure_compatible(self, other_path):
1216 + """Make sure the wildcards agree."""
1217 + if self.ends_triple_dot != other_path.ends_triple_dot:
1218 + die("Both paths must end with ... if either does;\n" +
1219 + "paths: %s %s" % (self.path, other_path.path))
1220 +
1221 + def match_wildcards(self, test_path):
1222 + """See if this test_path matches us, and fill in the value
1223 + of the wildcards if so. Returns a tuple of
1224 + (True|False, wildcards[]). For now, only the ... at end
1225 + is supported, so at most one wildcard."""
1226 + if self.ends_triple_dot:
1227 + dotless = self.path[:-3]
1228 + if test_path.startswith(dotless):
1229 + wildcard = test_path[len(dotless):]
1230 + return (True, [ wildcard ])
1231 + else:
1232 + if test_path == self.path:
1233 + return (True, [])
1234 + return (False, [])
1235 +
1236 + def match(self, test_path):
1237 + """Just return if it matches; don't bother with the wildcards."""
1238 + b, _ = self.match_wildcards(test_path)
1239 + return b
1240 +
1241 + def fill_in_wildcards(self, wildcards):
1242 + """Return the relative path, with the wildcards filled in
1243 + if there are any."""
1244 + if self.ends_triple_dot:
1245 + return self.path[:-3] + wildcards[0]
1246 + else:
1247 + return self.path
1248 +
1249 + class Mapping(object):
1250 + def __init__(self, depot_side, client_side, overlay, exclude):
1251 + # depot_side is without the trailing /... if it had one
1252 + self.depot_side = View.Path(depot_side, is_depot=True)
1253 + self.client_side = View.Path(client_side, is_depot=False)
1254 + self.overlay = overlay # started with "+"
1255 + self.exclude = exclude # started with "-"
1256 + assert not (self.overlay and self.exclude)
1257 + self.depot_side.ensure_compatible(self.client_side)
1258 +
1259 + def __str__(self):
1260 + c = " "
1261 + if self.overlay:
1262 + c = "+"
1263 + if self.exclude:
1264 + c = "-"
1265 + return "View.Mapping: %s%s -> %s" % \
1266 + (c, self.depot_side, self.client_side)
1267 +
1268 + def map_depot_to_client(self, depot_path):
1269 + """Calculate the client path if using this mapping on the
1270 + given depot path; does not consider the effect of other
1271 + mappings in a view. Even excluded mappings are returned."""
1272 + matches, wildcards = self.depot_side.match_wildcards(depot_path)
1273 + if not matches:
1274 + return ""
1275 + client_path = self.client_side.fill_in_wildcards(wildcards)
1276 + return client_path
1277 +
1278 + #
1279 + # View methods
1280 + #
1281 + def __init__(self):
1282 + self.mappings = []
1283 +
1284 + def append(self, view_line):
1285 + """Parse a view line, splitting it into depot and client
1286 + sides. Append to self.mappings, preserving order."""
1287 +
1288 + # Split the view line into exactly two words. P4 enforces
1289 + # structure on these lines that simplifies this quite a bit.
1290 + #
1291 + # Either or both words may be double-quoted.
1292 + # Single quotes do not matter.
1293 + # Double-quote marks cannot occur inside the words.
1294 + # A + or - prefix is also inside the quotes.
1295 + # There are no quotes unless they contain a space.
1296 + # The line is already white-space stripped.
1297 + # The two words are separated by a single space.
1298 + #
1299 + if view_line[0] == '"':
1300 + # First word is double quoted. Find its end.
1301 + close_quote_index = view_line.find('"', 1)
1302 + if close_quote_index <= 0:
1303 + die("No first-word closing quote found: %s" % view_line)
1304 + depot_side = view_line[1:close_quote_index]
1305 + # skip closing quote and space
1306 + rhs_index = close_quote_index + 1 + 1
1307 + else:
1308 + space_index = view_line.find(" ")
1309 + if space_index <= 0:
1310 + die("No word-splitting space found: %s" % view_line)
1311 + depot_side = view_line[0:space_index]
1312 + rhs_index = space_index + 1
1313 +
1314 + if view_line[rhs_index] == '"':
1315 + # Second word is double quoted. Make sure there is a
1316 + # double quote at the end too.
1317 + if not view_line.endswith('"'):
1318 + die("View line with rhs quote should end with one: %s" %
1319 + view_line)
1320 + # skip the quotes
1321 + client_side = view_line[rhs_index+1:-1]
1322 + else:
1323 + client_side = view_line[rhs_index:]
1324 +
1325 + # prefix + means overlay on previous mapping
1326 + overlay = False
1327 + if depot_side.startswith("+"):
1328 + overlay = True
1329 + depot_side = depot_side[1:]
1330 +
1331 + # prefix - means exclude this path
1332 + exclude = False
1333 + if depot_side.startswith("-"):
1334 + exclude = True
1335 + depot_side = depot_side[1:]
1336 +
1337 + m = View.Mapping(depot_side, client_side, overlay, exclude)
1338 + self.mappings.append(m)
1339 +
1340 + def map_in_client(self, depot_path):
1341 + """Return the relative location in the client where this
1342 + depot file should live. Returns "" if the file should
1343 + not be mapped in the client."""
1344 +
1345 + paths_filled = []
1346 + client_path = ""
1347 +
1348 + # look at later entries first
1349 + for m in self.mappings[::-1]:
1350 +
1351 + # see where will this path end up in the client
1352 + p = m.map_depot_to_client(depot_path)
1353 +
1354 + if p == "":
1355 + # Depot path does not belong in client. Must remember
1356 + # this, as previous items should not cause files to
1357 + # exist in this path either. Remember that the list is
1358 + # being walked from the end, which has higher precedence.
1359 + # Overlap mappings do not exclude previous mappings.
1360 + if not m.overlay:
1361 + paths_filled.append(m.client_side)
1362 +
1363 + else:
1364 + # This mapping matched; no need to search any further.
1365 + # But, the mapping could be rejected if the client path
1366 + # has already been claimed by an earlier mapping.
1367 + already_mapped_in_client = False
1368 + for f in paths_filled:
1369 + # this is View.Path.match
1370 + if f.match(p):
1371 + already_mapped_in_client = True
1372 + break
1373 + if not already_mapped_in_client:
1374 + # Include this file, unless it is from a line that
1375 + # explicitly said to exclude it.
1376 + if not m.exclude:
1377 + client_path = p
1378 +
1379 + # a match, even if rejected, always stops the search
1380 + break
1381 +
1382 + return client_path
1383 +
1384 class P4Sync(Command, P4UserMap):
1385 delete_actions = ( "delete", "move/delete", "purge" )
1386
@@ -1216,8 +1428,7 @@ class P4Sync(Command, P4UserMap):
1428 self.p4BranchesInGit = []
1429 self.cloneExclude = []
1430 self.useClientSpec = False
1219 - self.clientSpecDirs = []
1220 - self.haveSingleFileClientViews = False
1431 + self.clientSpecDirs = None
1432
1433 if gitConfig("git-p4.syncFromOrigin") == "false":
1434 self.syncWithOrigin = False
@@ -1268,30 +1479,7 @@ class P4Sync(Command, P4UserMap):
1479
1480 def stripRepoPath(self, path, prefixes):
1481 if self.useClientSpec:
1271 -
1272 - # if using the client spec, we use the output directory
1273 - # specified in the client. For example, a view
1274 - # //depot/foo/branch/... //client/branch/foo/...
1275 - # will end up putting all foo/branch files into
1276 - # branch/foo/
1277 - for val in self.clientSpecDirs:
1278 - if self.haveSingleFileClientViews and len(path) == abs(val[1][0]) and path == val[0]:
1279 - # since 'path' is a depot file path, if it matches the LeftMap,
1280 - # then the View is a single file mapping, so use the entire rightMap
1281 - # first two tests above avoid the last == test for common cases
1282 - path = val[1][1]
1283 - # now strip out the client (//client/...)
1284 - path = re.sub("^(//[^/]+/)", '', path)
1285 - # the rest is local client path
1286 - return path
1287 -
1288 - if path.startswith(val[0]):
1289 - # replace the depot path with the client path
1290 - path = path.replace(val[0], val[1][1])
1291 - # now strip out the client (//client/...)
1292 - path = re.sub("^(//[^/]+/)", '', path)
1293 - # the rest is all path
1294 - return path
1482 + return self.clientSpecDirs.map_in_client(path)
1483
1484 if self.keepRepoPath:
1485 prefixes = [re.sub("^(//[^/]+/).*", r'\1', prefixes[0])]
@@ -1441,19 +1629,17 @@ class P4Sync(Command, P4UserMap):
1629 filesToDelete = []
1630
1631 for f in files:
1444 - includeFile = True
1445 - for val in self.clientSpecDirs:
1446 - if f['path'].startswith(val[0]):
1447 - if val[1][0] <= 0:
1448 - includeFile = False
1449 - break
1632 + # if using a client spec, only add the files that have
1633 + # a path in the client
1634 + if self.clientSpecDirs:
1635 + if self.clientSpecDirs.map_in_client(f['path']) == "":
1636 + continue
1637
1451 - if includeFile:
1452 - filesForCommit.append(f)
1453 - if f['action'] in self.delete_actions:
1454 - filesToDelete.append(f)
1455 - else:
1456 - filesToRead.append(f)
1638 + filesForCommit.append(f)
1639 + if f['action'] in self.delete_actions:
1640 + filesToDelete.append(f)
1641 + else:
1642 + filesToRead.append(f)
1643
1644 # deleted files...
1645 for f in filesToDelete:
@@ -1892,60 +2078,31 @@ class P4Sync(Command, P4UserMap):
2078
2079
2080 def getClientSpec(self):
1895 - specList = p4CmdList( "client -o" )
1896 - temp = {}
1897 - for entry in specList:
1898 - for k,v in entry.iteritems():
1899 - if k.startswith("View"):
1900 -
1901 - # p4 has these %%1 to %%9 arguments in specs to
1902 - # reorder paths; which we can't handle (yet :)
1903 - if re.search('%%\d', v) != None:
1904 - print "Sorry, can't handle %%n arguments in client specs"
1905 - sys.exit(1)
1906 - if re.search('\*', v) != None:
1907 - print "Sorry, can't handle * mappings in client specs"
1908 - sys.exit(1)
1909 -
1910 - if v.startswith('"'):
1911 - start = 1
1912 - else:
1913 - start = 0
1914 - index = v.find("...")
1915 -
1916 - # save the "client view"; i.e the RHS of the view
1917 - # line that tells the client where to put the
1918 - # files for this view.
1919 -
1920 - # check for individual file mappings - those which have no '...'
1921 - if index < 0 :
1922 - v,cv = v.strip().split()
1923 - v = v[start:]
1924 - self.haveSingleFileClientViews = True
1925 - else:
1926 - cv = v[index+3:].strip() # +3 to remove previous '...'
1927 - cv=cv[:-3]
1928 - v = v[start:index]
1929 -
1930 - # now save the view; +index means included, -index
1931 - # means it should be filtered out.
1932 - if v.startswith("-"):
1933 - v = v[1:]
1934 - include = -len(v)
1935 - else:
1936 - include = len(v)
2081 + specList = p4CmdList("client -o")
2082 + if len(specList) != 1:
2083 + die('Output from "client -o" is %d lines, expecting 1' %
2084 + len(specList))
2085 +
2086 + # dictionary of all client parameters
2087 + entry = specList[0]
2088 +
2089 + # just the keys that start with "View"
2090 + view_keys = [ k for k in entry.keys() if k.startswith("View") ]
2091 +
2092 + # hold this new View
2093 + view = View()
2094
1938 - # store the View #number for sorting
1939 - # and the View string itself (this last for documentation)
1940 - temp[v] = (include, cv, int(k[4:]),k)
2095 + # append the lines, in order, to the view
2096 + for view_num in range(len(view_keys)):
2097 + k = "View%d" % view_num
2098 + if k not in view_keys:
2099 + die("Expected view key %s missing" % k)
2100 + view.append(entry[k])
2101
1942 - self.clientSpecDirs = temp.items()
1943 - # Perforce ViewNN with higher #numbers override those with lower
1944 - # reverse sort on the View #number
1945 - self.clientSpecDirs.sort( lambda x, y: y[1][2] - x[1][2] )
2102 + self.clientSpecDirs = view
2103 if self.verbose:
1947 - for val in self.clientSpecDirs:
1948 - print "clientSpecDirs: %s %s" % (val[0],val[1])
2104 + for i, m in enumerate(self.clientSpecDirs.mappings):
2105 + print "clientSpecDirs %d: %s" % (i, str(m))
2106
2107 def run(self, args):
2108 self.depotPaths = []
t/t9809-git-p4-client-view.sh
+12 -12
@@ -107,7 +107,7 @@ test_expect_success 'wildcard ... only supported at end of spec' '
107 test_must_fail "$GITP4" clone --use-client-spec --dest="$git" //depot
108 '
109
110 -test_expect_failure 'basic map' '
110 +test_expect_success 'basic map' '
111 client_view "//depot/dir1/... //client/cli1/..." &&
112 files="cli1/file11 cli1/file12" &&
113 client_verify $files &&
@@ -116,7 +116,7 @@ test_expect_failure 'basic map' '
116 git_verify $files
117 '
118
119 -test_expect_failure 'client view with no mappings' '
119 +test_expect_success 'client view with no mappings' '
120 client_view &&
121 client_verify &&
122 test_when_finished cleanup_git &&
@@ -124,7 +124,7 @@ test_expect_failure 'client view with no mappings' '
124 git_verify
125 '
126
127 -test_expect_failure 'single file map' '
127 +test_expect_success 'single file map' '
128 client_view "//depot/dir1/file11 //client/file11" &&
129 files="file11" &&
130 client_verify $files &&
@@ -144,7 +144,7 @@ test_expect_success 'later mapping takes precedence (entire repo)' '
144 git_verify $files
145 '
146
147 -test_expect_failure 'later mapping takes precedence (partial repo)' '
147 +test_expect_success 'later mapping takes precedence (partial repo)' '
148 client_view "//depot/dir1/... //client/..." \
149 "//depot/dir2/... //client/..." &&
150 files="file21 file22" &&
@@ -159,7 +159,7 @@ test_expect_failure 'later mapping takes precedence (partial repo)' '
159 # dir1 cannot go to cli12 since it was filled by dir2
160 # dir1 also does not go to cli3, since the second rule
161 # noticed that it matched, but was already filled
162 -test_expect_failure 'depot path matching rejected client path' '
162 +test_expect_success 'depot path matching rejected client path' '
163 client_view "//depot/dir1/... //client/cli3/..." \
164 "//depot/dir1/... //client/cli12/..." \
165 "//depot/dir2/... //client/cli12/..." &&
@@ -172,7 +172,7 @@ test_expect_failure 'depot path matching rejected client path' '
172
173 # since both have the same //client/..., the exclusion
174 # rule keeps everything out
175 -test_expect_failure 'exclusion wildcard, client rhs same (odd)' '
175 +test_expect_success 'exclusion wildcard, client rhs same (odd)' '
176 client_view "//depot/... //client/..." \
177 "-//depot/dir2/... //client/..." &&
178 client_verify &&
@@ -201,7 +201,7 @@ test_expect_success 'exclusion single file' '
201 git_verify $files
202 '
203
204 -test_expect_failure 'overlay wildcard' '
204 +test_expect_success 'overlay wildcard' '
205 client_view "//depot/dir1/... //client/cli/..." \
206 "+//depot/dir2/... //client/cli/...\n" &&
207 files="cli/file11 cli/file12 cli/file21 cli/file22" &&
@@ -211,7 +211,7 @@ test_expect_failure 'overlay wildcard' '
211 git_verify $files
212 '
213
214 -test_expect_failure 'overlay single file' '
214 +test_expect_success 'overlay single file' '
215 client_view "//depot/dir1/... //client/cli/..." \
216 "+//depot/dir2/file21 //client/cli/file21" &&
217 files="cli/file11 cli/file12 cli/file21" &&
@@ -221,7 +221,7 @@ test_expect_failure 'overlay single file' '
221 git_verify $files
222 '
223
224 -test_expect_failure 'exclusion with later inclusion' '
224 +test_expect_success 'exclusion with later inclusion' '
225 client_view "//depot/... //client/..." \
226 "-//depot/dir2/... //client/dir2/..." \
227 "//depot/dir2/... //client/dir2incl/..." &&
@@ -232,7 +232,7 @@ test_expect_failure 'exclusion with later inclusion' '
232 git_verify $files
233 '
234
235 -test_expect_failure 'quotes on rhs only' '
235 +test_expect_success 'quotes on rhs only' '
236 client_view "//depot/dir1/... \"//client/cdir 1/...\"" &&
237 client_verify "cdir 1/file11" "cdir 1/file12" &&
238 test_when_finished cleanup_git &&
@@ -266,7 +266,7 @@ test_expect_success 'rename files to introduce spaces' '
266 "dir 2/file21" "dir 2/file22"
267 '
268
269 -test_expect_failure 'quotes on lhs only' '
269 +test_expect_success 'quotes on lhs only' '
270 client_view "\"//depot/dir 1/...\" //client/cdir1/..." &&
271 files="cdir1/file11 cdir1/file12" &&
272 client_verify $files &&
@@ -275,7 +275,7 @@ test_expect_failure 'quotes on lhs only' '
275 client_verify $files
276 '
277
278 -test_expect_failure 'quotes on both sides' '
278 +test_expect_success 'quotes on both sides' '
279 client_view "\"//depot/dir 1/...\" \"//client/cdir 1/...\"" &&
280 client_verify "cdir 1/file11" "cdir 1/file12" &&
281 test_when_finished cleanup_git &&