pw_build: pw_mirror_tree target

The pw_mirror_tree target makes source files available in the output
directory. This can be used to efficiently add a prefix to a source
tree.

Change-Id: I9f5458e2b786ab8ca0132b6e85f4df57a1c267a5
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/34980
Commit-Queue: Wyatt Hepler <hepler@google.com>
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Reviewed-by: Alexei Frolov <frolv@google.com>
diff --git a/pw_build/mirror_tree.gni b/pw_build/mirror_tree.gni
new file mode 100644
index 0000000..0551d58
--- /dev/null
+++ b/pw_build/mirror_tree.gni
@@ -0,0 +1,61 @@
+# Copyright 2021 The Pigweed Authors
+#
+# 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
+#
+#     https://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.
+
+import("//build_overrides/pigweed.gni")
+
+# Mirrors a directory structure to the output directory.
+#
+# This is similar to a GN copy target, with some differences:
+#
+#   - The outputs list is generated by the template based on the source_root and
+#     directory arguments, rather than using source expansion.
+#   - The source_root argument can be used to trim prefixes from source files.
+#   - pw_mirror_tree uses hard links instead of copies for efficiency.
+#
+# Args:
+#
+#   directory: Output directory for the files.
+#   sources: List of files to mirror to the output directory.
+#   source_root: Root path for sources; defaults to ".".
+#
+template("pw_mirror_tree") {
+  assert(defined(invoker.sources) && invoker.sources != [],
+         "At least one source file must be provided in 'sources'")
+  assert(defined(invoker.directory) && invoker.directory != "",
+         "The output path must be specified as 'directory'")
+
+  if (defined(invoker.source_root)) {
+    _root = invoker.source_root
+  } else {
+    _root = "."
+  }
+
+  action(target_name) {
+    script = "$dir_pw_build/py/pw_build/mirror_tree.py"
+
+    outputs = []
+    foreach(path, rebase_path(invoker.sources, _root)) {
+      outputs += [ "${invoker.directory}/$path" ]
+    }
+
+    args = [
+             "--source-root",
+             rebase_path(_root),
+             "--directory",
+             rebase_path(invoker.directory),
+           ] + rebase_path(invoker.sources)
+
+    forward_variables_from(invoker, "*", [ "script" ])
+  }
+}
diff --git a/pw_build/py/BUILD.gn b/pw_build/py/BUILD.gn
index b250061..e7b6185 100644
--- a/pw_build/py/BUILD.gn
+++ b/pw_build/py/BUILD.gn
@@ -25,6 +25,7 @@
     "pw_build/generate_python_package_gn.py",
     "pw_build/generated_tests.py",
     "pw_build/host_tool.py",
+    "pw_build/mirror_tree.py",
     "pw_build/nop.py",
     "pw_build/null_backend.py",
     "pw_build/python_runner.py",
diff --git a/pw_build/py/pw_build/mirror_tree.py b/pw_build/py/pw_build/mirror_tree.py
new file mode 100644
index 0000000..6133410
--- /dev/null
+++ b/pw_build/py/pw_build/mirror_tree.py
@@ -0,0 +1,62 @@
+# Copyright 2021 The Pigweed Authors
+#
+# 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
+#
+#     https://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.
+"""Mirrors a directory tree to another directory using hard links."""
+
+import argparse
+import os
+from pathlib import Path
+from typing import Iterable, List
+
+
+def _parse_args() -> argparse.Namespace:
+    """Registers the script's arguments on an argument parser."""
+
+    parser = argparse.ArgumentParser(description=__doc__)
+
+    parser.add_argument('--source-root',
+                        type=Path,
+                        required=True,
+                        help='Prefix to strip from the source files')
+    parser.add_argument('sources',
+                        type=Path,
+                        nargs='+',
+                        help='Files to mirror to the directory')
+    parser.add_argument('--directory',
+                        type=Path,
+                        required=True,
+                        help='Directory to which to mirror the sources')
+
+    return parser.parse_args()
+
+
+def mirror_paths(source_root: Path, sources: Iterable[Path],
+                 directory: Path) -> List[Path]:
+    outputs: List[Path] = []
+
+    for source in sources:
+        dest = directory / source.relative_to(source_root)
+        dest.parent.mkdir(parents=True, exist_ok=True)
+
+        if dest.exists():
+            dest.unlink()
+
+        # Use a hard link to avoid unnecessary copies.
+        os.link(source, dest)
+        outputs.append(dest)
+
+    return outputs
+
+
+if __name__ == '__main__':
+    mirror_paths(**vars(_parse_args()))