import argparse import re import subprocess import sys import tempfile from pathlib import Path LISTS = "core/libs/prx/libSceAgcDriver/CMakeLists.txt" FRAGMENTS = "core/libs/prx/libSceAgcDriver/tests/cmake" EXECUTION = "core/libs/prx/libSceAgcDriver/tests/execution" INCLUDE = [ "file(GLOB agcTestFragments CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/tests/cmake/*.cmake)", "list(SORT agcTestFragments)", "foreach(agcTestFragment IN LISTS agcTestFragments)", " include(${agcTestFragment})", "endforeach()", ] OPEN = {"if": "endif", "foreach": "endforeach", "while": "endwhile", "function": "endfunction", "macro": "endmacro", "block": "endblock"} BRANCH = {"elseif", "else"} CALL = re.compile(r"[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*\(") UNIT = re.compile(r"[a-z][a-z0-9_]*_tests") MENTION = re.compile(r"(?= len(text): raise SplitError(f"line {line + 1}: unterminated command") character = text[position] if character == "\n": line += 1 if character == '"': end = position + 1 while text[end] != '"': if text[end] == "\\": end += 1 if text[end] == "\n": line += 1 end += 1 token = (token or "") + text[position + 1:end] position = end + 1 continue bracket = re.match(r"\[(=*)\[", text[position:position + 32]) if bracket and token is None: close = text.index("]" + bracket[1] + "]", position) value = text[position + len(bracket[0]):close] line += value.count("\n") arguments.append(value) position = close + len(bracket[0]) continue if character == "#": comment = re.match(r"#\[(=*)\[", text[position:position + 32]) close = text.index("]" + comment[1] + "]", position) + len(comment[0]) if comment else text.find("\n", position) line += text[position:close].count("\n") position = close continue if character == "\\" and token is not None: token += text[position:position + 2] position += 2 continue if character in " \t\r\n()": if token is not None: arguments.append(token) token = None if character == "(": depth += 1 arguments.append("(") elif character == ")": if depth == 0: return arguments, position + 1, line depth -= 1 arguments.append(")") position += 1 continue token = (token or "") + character position += 1 def commands(text): lines = text.split("\n") offsets = [0] for item in lines: offsets.append(offsets[-1] + len(item) + 1) found, index = [], 0 while index < len(lines): stripped = lines[index].strip() if not stripped or stripped.startswith("#"): index += 1 continue call = CALL.match(lines[index]) if not call: raise SplitError(f"line {index + 1}: not a command: {lines[index]}") arguments, end, last = arguments_of(text, offsets[index] + call.end(), index) rest = text[end:offsets[last + 1] - 1] if last + 1 < len(offsets) else text[end:] if rest.strip() and not rest.strip().startswith("#"): raise SplitError(f"line {last + 1}: more than one command on a line") found.append(Command(call[1].lower(), arguments, index, last)) index = last + 1 return found def tree(text): root, stack = [], [] for command in commands(text): if command.name in OPEN: block = Block(command) (stack[-1].branches[-1][1] if stack else root).append(block) stack.append(block) elif command.name in BRANCH: if not stack or stack[-1].header.name != "if": raise SplitError(f"line {command.first + 1}: {command.name} outside if") stack[-1].branches.append((command, [])) elif command.name in OPEN.values(): if not stack or OPEN[stack[-1].header.name] != command.name: raise SplitError(f"line {command.first + 1}: unmatched {command.name}") stack.pop().end = command else: (stack[-1].branches[-1][1] if stack else root).append(command) if stack: raise SplitError(f"line {stack[-1].first + 1}: unclosed {stack[-1].header.name}") return root def flatten(node): if isinstance(node, Command): yield node return for branch, children in node.branches: yield branch for child in children: yield from flatten(child) yield node.end class Atom: def __init__(self, node, conditions): self.node = node self.conditions = conditions def flat(self): return flatten(self.node) def atoms(nodes, conditions=()): for node in nodes: if isinstance(node, Block) and node.header.name == "if" and len(node.branches) == 1: yield from atoms(node.branches[0][1], conditions + (node,)) else: yield Atom(node, conditions) def enclosing_loop(lines, start): depth = 0 for index in range(start, len(lines)): stripped = lines[index].strip() if re.match(r"foreach\s*\(", stripped): depth += 1 elif re.match(r"endforeach\s*\(", stripped): depth -= 1 if depth == 0: return start, index, len(lines[start]) - len(lines[start].lstrip()) raise SplitError(f"line {start + 1}: unterminated loop") def find_loop(lines, predicate, what): starts = [index for index, line in enumerate(lines) if predicate(index, line.strip())] if len(starts) > 1: raise SplitError(f"more than one {what}") return enclosing_loop(lines, starts[0]) if starts else None def dedent(lines, amount): return [line[amount:] if line.startswith(" " * amount) else line for line in lines] def loop_list(header): return header[header.index("ITEMS") + len("ITEMS"):header.rindex(")")].split() def default_tests(lines): found = find_loop(lines, lambda index, line: line.startswith("foreach(agcTest IN ITEMS ") and "${agcExecutionTests}" in line, "default-test loop") if found is None: raise SplitError("the default-test loop over ${agcExecutionTests} is missing") start, end, indent = found body = lines[start + 1:end] skip = [] if body and body[0].strip().startswith("if(agcTest STREQUAL"): close = next(index for index, line in enumerate(body) if line.strip() == "endif()") skip, body = body[:close + 1], body[close + 1:] kept, tweaks, index = [], [], 0 while index < len(body): special = SPECIAL.fullmatch(body[index].strip()) if not special: kept.append(body[index]) index += 1 continue depth = len(body[index]) - len(body[index].lstrip()) close = next(position for position in range(index, len(body)) if body[position].strip() == "endif()" and len(body[position]) - len(body[position].lstrip()) == depth) inner = body[index + 1:close] if not inner or not inner[0].strip().startswith("set_tests_properties(${agcTestName} "): raise SplitError(f"line {start + index + 2}: a default-test case does more than set properties") tweaks += [" " * indent + line.strip().replace("${agcTestName}", special[1]) if position == 0 else line[8:] for position, line in enumerate(inner)] index = close + 1 pad = " " * indent lines[start:end + 1] = [f"{pad}agc_add_default_test({name})" for name in loop_list(lines[start]) if name != "${agcExecutionTests}"] + tweaks function = ["function(agc_add_default_test agcTest)"] + dedent(kept, indent) + ["endfunction()"] execution = ["if(BUILD_TESTING)", " foreach(agcTest IN LISTS agcExecutionTests)"] + \ [" " * 8 + line[indent + 4:] for line in skip] + [" agc_add_default_test(${agcTest})", " endforeach()", "endif()"] return function, execution def draw_pipeline_tests(lines): found = find_loop(lines, lambda index, line: line == "foreach(agcTestName IN LISTS agcDrawPipelineTests)", "draw pipeline loop") if found is None: return None start, end, indent = found listing = lines[start - 1].strip() if not listing.startswith("set(agcDrawPipelineTests "): raise SplitError("agcDrawPipelineTests is not set right before its loop") names = listing[len("set(agcDrawPipelineTests "):-1].split() body = lines[start + 1:end] lines[start - 1:end + 1] = [f"{' ' * indent}agc_add_draw_pipeline_test({name})" for name in names] return ["function(agc_add_draw_pipeline_test agcTestName)"] + dedent(body, indent) + ["endfunction()"] def spirv_mode_tests(lines): found = find_loop(lines, lambda index, line: line.startswith("foreach(agcTest IN ITEMS ") and index + 1 < len(lines) and lines[index + 1].strip() == "if(NOT TARGET ${agcTest})", "SPIR-V mode loop") if found is None: return None start, end, indent = found body = lines[start + 1:end] if body[1].strip() != "continue()": raise SplitError("the SPIR-V mode loop no longer starts with its target check") body[1] = body[1].replace("continue()", "return()") lines[start:end + 1] = [f"{' ' * indent}agc_add_spirv_mode_tests({name})" for name in loop_list(lines[start])] return ["function(agc_add_spirv_mode_tests agcTest)"] + dedent(body, indent) + ["endfunction()"] def refactor(text): if "function(agc_add_default_test " in text: return text lines = text.split("\n") function, execution = default_tests(lines) functions = [function] for convert in (draw_pipeline_tests, spirv_mode_tests): function = convert(lines) if function: functions.append(function) glob = [index for index, line in enumerate(lines) if line.startswith("foreach(agcExecutionTestSource IN LISTS agcExecutionTestSources)")] if len(glob) != 1: raise SplitError("the execution-test loop is missing") close = next(index for index in range(glob[0], len(lines)) if lines[index] == "endforeach()") added = [""] for function in functions: added += function + [""] lines[close + 1:close + 1] = added + execution + [""] return "\n".join(lines) class Facts: def __init__(self): self.targets = set() self.tests = {} self.dynamic_tests = [] self.subject_targets = set() self.subject_tests = set() self.test_patterns = set() self.dynamic = set() self.defines = set() self.accumulates = set() self.uses = set() self.loop = set() self.mentions = set() self.calls = set() self.functions = set() self.reads_all_tests = False self.creates_defaults = False self.dynamic_targets = False def until(arguments, keywords): for index, argument in enumerate(arguments): if argument in keywords: return arguments[:index] return arguments def literal(name): return "${" not in name and "$<" not in name def subject(facts, names, kind): for name in names: if literal(name): (facts.subject_targets if kind == "target" else facts.subject_tests).add(name) else: facts.dynamic.update(VARIABLE.findall(name)) if kind == "test": facts.test_patterns.add(name) def loop_items(atom): items = {} for command in atom.flat(): arguments = command.arguments if command.name != "foreach" or not arguments: continue rest = arguments[1:] if rest[:1] == ["IN"]: if rest[1:2] != ["ITEMS"]: continue rest = rest[2:] elif rest[:1] == ["RANGE"]: bounds = rest[1:] if not all(bound.isdigit() for bound in bounds) or not 1 <= len(bounds) <= 3: continue numbers = [int(bound) for bound in bounds] start, stop = (0, numbers[0]) if len(numbers) == 1 else (numbers[0], numbers[1]) rest = [str(value) for value in range(start, stop + 1, numbers[2] if len(numbers) == 3 else 1)] if all(literal(item) for item in rest): items[arguments[0]] = rest return items def expand(name, items): names = [name] for variable in set(VARIABLE.findall(name)): if variable not in items: return [] names = [candidate.replace("${" + variable + "}", value) for candidate in names for value in items[variable]] return names def facts_of(atom): facts = Facts() if isinstance(atom.node, Block) and atom.node.header.name in ("function", "macro"): facts.functions.add(atom.node.header.arguments[0].lower()) return facts for command in atom.flat(): arguments, name = command.arguments, command.name facts.calls.add(name) for argument in arguments: facts.uses.update(VARIABLE.findall(argument)) if "$<" not in argument: facts.mentions.update(MENTION.findall(argument)) if name in ("if", "elseif", "while"): facts.uses.update(argument for argument in arguments if IDENT.fullmatch(argument)) if name == "foreach" and arguments: facts.loop.add(arguments[0]) if "LISTS" in arguments: facts.uses.update(argument for argument in arguments[arguments.index("LISTS") + 1:] if IDENT.fullmatch(argument) and argument != "ITEMS") for index, argument in enumerate(arguments[1:-1], 1): if argument == "IN_LIST": facts.uses.add(arguments[index + 1]) if name in ("list", "string") and len(arguments) > 1 and arguments[0] in LIST_READERS: facts.uses.add(arguments[1]) facts.accumulates.add(arguments[1]) if name in SETTERS: for index, argument in enumerate(arguments): if IDENT.fullmatch(argument) and not (name == "foreach" and index): facts.defines.add(argument) if name in ("set", "unset", "option", "foreach"): break if name in HELPERS and arguments: if literal(arguments[0]): executable, tests = HELPERS[name](arguments[0]) facts.tests.update({test: executable for test in tests}) if name != "agc_add_draw_pipeline_test": facts.subject_targets.add(arguments[0]) else: facts.dynamic.update(VARIABLE.findall(arguments[0])) facts.dynamic_tests.append((arguments[0], None)) facts.creates_defaults = True if name in DEFINERS and arguments: if literal(arguments[0]): facts.targets.add(arguments[0]) else: facts.dynamic.update(VARIABLE.findall(arguments[0])) facts.dynamic_targets = True elif name == "add_test" and arguments: if arguments[0] == "NAME": test = arguments[1] rest = until(arguments[2:], TEST_KEYWORDS) executable = rest[rest.index("COMMAND") + 1] if "COMMAND" in rest[:-1] else None else: test, executable = arguments[0], arguments[1] if len(arguments) > 1 else None if literal(test): facts.tests[test] = executable else: facts.dynamic_tests.append((test, executable)) elif name in SUBJECT_FIRST and arguments: subject(facts, arguments[:1], "target") elif name == "add_asan_test_options" and arguments: subject(facts, arguments[:1], "test") elif name == "set_target_properties": subject(facts, until(arguments, {"PROPERTIES"}), "target") elif name == "set_tests_properties": subject(facts, until(arguments, {"PROPERTIES", "DIRECTORY"}), "test") elif name == "set_property" and len(arguments) > 1 and arguments[0] in ("TARGET", "TEST"): subject(facts, until(arguments[1:], {"PROPERTY", "APPEND", "APPEND_STRING", "DIRECTORY"}), arguments[0].lower()) elif name in ("get_target_property", "get_test_property") and len(arguments) > 1: subject(facts, arguments[1:2], "target" if name == "get_target_property" else "test") elif name == "get_directory_property" and "TESTS" in arguments: facts.reads_all_tests = True elif name not in CONTROL and name not in HELPERS and arguments and UNIT.fullmatch(arguments[0]): subject(facts, arguments[:1], "target") items = loop_items(atom) for test, executable in facts.dynamic_tests: for name in expand(test, items): facts.tests.setdefault(name, executable) facts.test_patterns = {prefix for prefix in (re.split(r"\$[{<]", name)[0] for name in facts.test_patterns - {test for test, _executable in facts.dynamic_tests}) if prefix} return facts def union_find(size): parent = list(range(size)) def find(index): while parent[index] != index: parent[index] = parent[parent[index]] index = parent[index] return index def join(first, second): parent[find(first)] = find(second) return find, join def reads(facts): return (facts.uses - (facts.defines - facts.accumulates)) | facts.accumulates class Plan: def __init__(self, text, execution=()): self.lines = text.split("\n") self.roots = tree(text) self.atoms = list(atoms(self.roots)) self.facts = [facts_of(atom) for atom in self.atoms] self.known = {name for facts in self.facts for name in facts.targets | facts.subject_targets | set(facts.tests.values()) if name and UNIT.fullmatch(name)} | set(execution) self.external = self.known - {name for facts in self.facts for name in facts.targets} self.test_units = {test: self.unit_of_test(test, executable) for facts in self.facts for test, executable in facts.tests.items()} self.units = self.assign() self.include = self.place() def unit_of_test_name(self, test): stems = [unit for unit in self.known if test == unit[:-6] or test.startswith(unit[:-6] + "_")] return max(stems, key=len) if stems else None def unit_of_test(self, test, executable): return executable if executable and literal(executable) and UNIT.fullmatch(executable) else self.unit_of_test_name(test) def candidates(self, index, test_owner): facts = self.facts[index] owners, unknown = set(), False for target in facts.targets | facts.subject_targets: if UNIT.fullmatch(target): owners.add(target) else: unknown = True for test, executable in facts.tests.items(): owner = self.unit_of_test(test, executable) if owner: owners.add(owner) for test in facts.subject_tests - set(facts.tests): if test in test_owner: owners.add(test_owner[test]) elif test + "_tests" in self.external: owners.add(test + "_tests") else: unknown = True for prefix in facts.test_patterns: matched = {unit for test, unit in self.test_units.items() if test.startswith(prefix)} if len(matched) == 1 and None not in matched: owners |= matched elif matched: unknown = True if facts.dynamic - facts.defines or facts.reads_all_tests or facts.creates_defaults or facts.dynamic_targets: unknown = True if facts.dynamic or facts.dynamic_tests: for _test, executable in facts.dynamic_tests: if executable and literal(executable) and UNIT.fullmatch(executable): owners.add(executable) if facts.dynamic and len(facts.mentions | owners) > 1: unknown = True owners |= facts.mentions return owners, unknown def write_groups(self): defining = {} for index, facts in enumerate(self.facts): for name in facts.defines: defining.setdefault(name, []).append(index) mutable = {name for name, indices in defining.items() if len(indices) > 1} mutable |= set().union(*(facts.accumulates for facts in self.facts)) find, join = union_find(len(self.atoms)) for index, facts in enumerate(self.facts): for name in reads(facts) & mutable & facts.defines: for earlier in reversed([other for other in defining[name] if other < index]): join(earlier, index) if name in self.facts[earlier].defines - self.facts[earlier].accumulates: break return [find(index) for index in range(len(self.atoms))] def variable_components(self, free): defining = {} find, join = union_find(len(self.atoms)) for index, facts in enumerate(self.facts): for name in reads(facts): for earlier in reversed(defining.get(name, [])): if free[earlier] or free[index]: join(earlier, index) if name in self.facts[earlier].defines - self.facts[earlier].accumulates: break for name in facts.defines - facts.loop: defining.setdefault(name, []).append(index) components = {} for index in range(len(self.atoms)): components.setdefault(find(index), []).append(index) return [indices for indices in components.values() if any(free[index] for index in indices)] def assign(self): group = self.write_groups() members = {} for index in range(len(self.atoms)): members.setdefault(group[index], []).append(index) test_owner, pinned = {}, set() while True: units, unknowns = [], [] for index in range(len(self.atoms)): owners, unknown = self.candidates(index, test_owner) units.append(owners) unknowns.append(unknown or group[index] in pinned) fresh_pinned = set(pinned) for root, indices in members.items(): if len(indices) > 1 and any(unknowns[index] for index in indices) and any(units[index] for index in indices): fresh_pinned.add(root) result = [] for index in range(len(self.atoms)): if unknowns[index] or group[index] in fresh_pinned: result.append(None) else: result.append(set().union(*(units[other] for other in members[group[index]])) or None) first = min((index for index in range(len(self.atoms)) if result[index]), default=len(self.atoms)) free = [not result[index] and not unknowns[index] and not units[index] and index > first and group[index] not in fresh_pinned for index in range(len(self.atoms))] for indices in self.variable_components(free): if any(not free[index] and not result[index] for index in indices): continue owners = {frozenset(result[index]) for index in indices if result[index]} if len(owners) == 1: for index in indices: if free[index]: result[index] = set(next(iter(owners))) fresh = {} for index, owners in enumerate(result): if owners and len(owners) == 1: for test in self.facts[index].tests: fresh[test] = next(iter(owners)) if fresh == test_owner and fresh_pinned == pinned: break test_owner, pinned = fresh, fresh_pinned names = sorted({name for owners in result if owners for name in owners}) position = {name: index for index, name in enumerate(names)} find, join = union_find(len(names)) sizes = {} for index, owners in enumerate(result): if not owners: continue ordered = sorted(owners) for name in ordered: join(position[name], position[ordered[0]]) sizes[name] = sizes.get(name, 0) + self.atoms[index].node.last - self.atoms[index].node.first + 1 largest = {} for name in names: root = find(position[name]) if root not in largest or (sizes[name], name) > (sizes[largest[root]], largest[root]): largest[root] = name return [largest[find(position[next(iter(owners))])] if owners else None for owners in result] def place(self): moved = [index for index, unit in enumerate(self.units) if unit] if not moved: raise SplitError(f"{LISTS} registers no tests to move") def union(field): return set().union(*(getattr(self.facts[index], field) for index in moved)) defined, used, targets, calls = union("defines"), union("uses"), union("targets"), union("calls") tests = set().union(*(set(self.facts[index].tests) for index in moved)) everywhere = set().union(*(facts.targets for facts in self.facts)) external = {name for name in union("subject_targets") if UNIT.fullmatch(name) and name not in everywhere} needed, depends = [], [] for index, facts in enumerate(self.facts): if self.units[index]: continue if ((facts.defines - facts.loop) & used or (facts.dynamic_targets and external) or facts.creates_defaults or facts.functions & calls): needed.append(index) if ((facts.uses - facts.defines) & defined or facts.subject_targets & targets or facts.subject_tests & tests or facts.mentions & targets or facts.reads_all_tests or any(test.startswith(prefix) for prefix in facts.test_patterns for test in tests)): depends.append(index) top = [next(position for position, node in enumerate(self.roots) if node.first <= atom.node.first <= node.last) for atom in self.atoms] low = top[max(needed)] + 1 if needed else 0 high = top[min(depends)] if depends else len(self.roots) if low > high: raise SplitError(f"no single place to include the fragments: line {self.atoms[max(needed)].node.first + 1} must come " f"before them and line {self.atoms[min(depends)].node.first + 1} after them") return max(low, min(high, max(top[index] for index in moved) + 1)) def leading(lines, first): while first > 0 and lines[first - 1].lstrip().startswith("#"): first -= 1 return first def opener(line): call = CALL.match(line) return bool(call) and (call[1].lower() in OPEN or call[1].lower() in BRANCH) def closer(line): call = CALL.match(line) return bool(call) and (call[1].lower() in OPEN.values() or call[1].lower() in BRANCH) def wrappers(nodes): for node in nodes: if isinstance(node, Block): for _branch, children in node.branches: yield from wrappers(children) yield node def fragment_text(lines, members): out, stack = [], [] for atom in members: conditions = list(atom.conditions) common = 0 while common < min(len(stack), len(conditions)) and stack[common] is conditions[common]: common += 1 while len(stack) > common: block = stack.pop() out.extend(lines[block.end.first:block.end.last + 1]) first = leading(lines, atom.node.first) separated = first > 0 and not lines[first - 1].strip() for block in conditions[common:]: if out and not stack and out[-1].strip(): out.append("") out.extend(lines[block.header.first:block.header.last + 1]) stack.append(block) if out and separated and out[-1].strip() and not opener(out[-1]): out.append("") out.extend(line for line in lines[first:atom.node.last + 1] if not line.lstrip().startswith("#")) while stack: block = stack.pop() out.extend(lines[block.end.first:block.end.last + 1]) return "\n".join(out) + "\n" def split_text(text, execution=()): plan = Plan(text, execution) lines = plan.lines units = {} for atom, unit in zip(plan.atoms, plan.units): if unit: units.setdefault(unit, []).append(atom) deleted = set() for members in units.values(): for atom in members: deleted.update(range(leading(lines, atom.node.first), atom.node.last + 1)) for block in wrappers(plan.roots): if block.header.name != "if" or len(block.branches) != 1: continue body = range(block.header.last + 1, block.end.first) if all(index in deleted or not lines[index].strip() or lines[index].lstrip().startswith("#") for index in body): deleted.update(range(leading(lines, block.header.first), block.end.last + 1)) at = leading(lines, plan.roots[plan.include].first) if plan.include < len(plan.roots) else len(lines) kept = [] for index, line in enumerate(lines): if index == at: kept.extend([""] + INCLUDE + [""]) if index not in deleted: kept.append(line) if at >= len(lines): kept.extend([""] + INCLUDE) residual = [] for line in kept: if not line.strip() and (not residual or not residual[-1].strip() or opener(residual[-1])): continue if closer(line) and residual and not residual[-1].strip(): residual.pop() residual.append(line) while residual and not residual[-1].strip(): residual.pop() return "\n".join(residual) + "\n", {name: fragment_text(lines, members) for name, members in units.items()} def migrated(text, execution=()): return split_text(refactor(text), execution) def execution_target(stem): return "agc_driver_" + re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", stem).lower() + "_tests" def execution_targets(root): return sorted(execution_target(path.stem) for path in (root / EXECUTION).glob("*.cpp")) def known_targets(root): known = set(execution_targets(root)) | {path.stem for path in (root / FRAGMENTS).glob("*.cmake")} revisions = ["HEAD"] for head in ("REBASE_HEAD", "CHERRY_PICK_HEAD", "MERGE_HEAD"): if git(root, "rev-parse", "-q", "--verify", head, check=False).strip(): revisions += [head, head + "^", git(root, "merge-base", "HEAD", head, check=False).strip()] for revision in filter(None, revisions): listing = git(root, "ls-tree", "--name-only", f"{revision}:{EXECUTION}", check=False) known |= {execution_target(Path(name).stem) for name in listing.splitlines() if name.endswith(".cpp")} return sorted(known) def is_split(text): return INCLUDE[0] in text def git(root, *args, check=True): return subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, encoding="utf-8", errors="replace", check=check).stdout def write_lf(path, text): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8", newline="\n") as file: file.write(text) def fragment_path(name): return f"{FRAGMENTS}/{name}.cmake" def command_refactor(root): text = (root / LISTS).read_text(encoding="utf-8") changed = refactor(text) if changed == text: sys.exit(f"{LISTS} already registers its default tests through agc_add_default_test") write_lf(root / LISTS, changed) print(LISTS) def command_split(root): text = (root / LISTS).read_text(encoding="utf-8") if is_split(text): sys.exit(f"{LISTS} already includes {FRAGMENTS}") lists, fragments = migrated(text, execution_targets(root)) existing = sorted(set(fragments) & {path.stem for path in (root / FRAGMENTS).glob("*.cmake")}) if existing: sys.exit(f"{FRAGMENTS} already has {', '.join(name + '.cmake' for name in existing)}") for name, body in fragments.items(): write_lf(root / fragment_path(name), body) write_lf(root / LISTS, lists) print(f"{len(fragments)} fragments in {FRAGMENTS}, {len(lists.splitlines())} lines left in {LISTS}") def merge(base, ours, theirs, label): if base == theirs: return ours, False if base == ours: return theirs, False with tempfile.TemporaryDirectory() as directory: paths = [] for name, text in (("ours", ours), ("base", base), ("theirs", theirs)): write_lf(Path(directory) / name, text) paths.append(str(Path(directory) / name)) result = subprocess.run(["git", "merge-file", "-p", "-L", f"{label} (split)", "-L", "before the split", "-L", f"{label} (change)", *paths], capture_output=True, text=True, encoding="utf-8") if not 0 <= result.returncode < 128: raise SplitError(f"git merge-file failed on {label}") return result.stdout, result.returncode > 0 def apply_change(base, change, lists, read, execution=()): base_lists, base_fragments = migrated(base, execution) change_lists, change_fragments = migrated(change, execution) written, conflicted = {}, [] merged, clash = merge(base_lists, lists, change_lists, LISTS) if merged != lists: written[LISTS] = merged if clash: conflicted.append(LISTS) for name in sorted(set(base_fragments) | set(change_fragments)): before, after = base_fragments.get(name, ""), change_fragments.get(name, "") if before == after: continue path = fragment_path(name) merged, clash = merge(before, read(path), after, path) written[path] = merged if clash: conflicted.append(path) return written, conflicted def command_resolve(root): stages = {} for line in git(root, "ls-files", "-u", "--", LISTS).splitlines(): meta, _ = line.split("\t", 1) _, sha, stage = meta.split() stages[stage] = sha if not {"1", "2", "3"} <= set(stages): sys.exit(f"{LISTS} is not in a three-way conflict") base, ours, theirs = (git(root, "cat-file", "-p", stages[stage]) for stage in "123") if is_split(base) or is_split(ours) == is_split(theirs): sys.exit(f"{LISTS}: the conflict is not between a change made before the split and the split; resolve it by hand") lists, change = (ours, theirs) if is_split(ours) else (theirs, ours) def read(path): file = root / path return file.read_text(encoding="utf-8") if file.exists() else "" try: written, conflicted = apply_change(base, change, lists, read, known_targets(root)) except SplitError as error: sys.exit(f"{LISTS}: {error}") written.setdefault(LISTS, lists) for path, text in written.items(): if text.strip(): write_lf(root / path, text) elif (root / path).exists(): (root / path).unlink() clean = [path for path in written if path not in conflicted] if clean: git(root, "add", "-A", "--", *clean) print("\n".join(sorted(written))) if conflicted: sys.exit("resolve the conflict markers in " + ", ".join(conflicted) + ", then git add them") def main(argv=None): for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8") parser = argparse.ArgumentParser(description=f"Move the AGC test registrations of {LISTS} into one fragment per test executable, " "and move registrations of a change made before the split") parser.add_argument("command", choices=("refactor", "split", "resolve"), help="refactor: register default, draw-pipeline and SPIR-V mode tests through functions; " f"split: refactor, then move the registrations into {FRAGMENTS}/; " f"resolve: during a rebase or merge stopped on {LISTS}, apply the change's registrations to the " "fragments and stage them") args = parser.parse_args(argv) root = Path(git(Path.cwd(), "rev-parse", "--show-toplevel").strip()) try: {"refactor": command_refactor, "split": command_split, "resolve": command_resolve}[args.command](root) except SplitError as error: sys.exit(f"{LISTS}: {error}") if __name__ == "__main__": main()