Use rsync to copy over files to Xcode locations

Rsync offers the following advantages:
- Only copy files if they've changed
- Only modify timestamps when files have changed
  - This allows Xcode to more intelligently copy files to device

Note that the artifact_root will only be valid for local builds;
remote builds will need to unzip the IPA or ZIP file.

Also note that currently dynamic frameworks will always be resigned
during an incremental device build; this will cause them to always
be copied over to device even if they have not changed.

PiperOrigin-RevId: 190488842
diff --git a/src/TulsiGenerator/Bazel/tulsi/tulsi_aspects.bzl b/src/TulsiGenerator/Bazel/tulsi/tulsi_aspects.bzl
index aa02a68..1bdf432 100644
--- a/src/TulsiGenerator/Bazel/tulsi/tulsi_aspects.bzl
+++ b/src/TulsiGenerator/Bazel/tulsi/tulsi_aspects.bzl
@@ -71,15 +71,6 @@
     'xibs',
 ]
 
-# List of rules with implicit <label>.ipa IPA outputs.
-# TODO(b/33050780): This is only used for the native rules and will be removed
-# in the future
-_IPA_GENERATING_RULES = [
-    'ios_application',
-    'ios_extension',
-    'tvos_application',
-]
-
 # List of rules that generate MergedInfo.plist files as part of the build.
 _MERGEDINFOPLIST_GENERATING_RULES = [
     'ios_application',
@@ -875,25 +866,18 @@
       if hasattr(dep, 'transitive_embedded_bundles'):
         embedded_bundles += dep.transitive_embedded_bundles
 
+  artifact = None
   bundle_name = None
+  archive_root = None
+  bundle_dir = None
   if AppleBundleInfo in target:
-    bundle_name = target[AppleBundleInfo].bundle_name
-    artifacts = [target[AppleBundleInfo].archive.path]
-  else:  # TODO(b/33050780): Remove this branch when native rules are deleted.
-    ipa_output_name = None
-    if target_kind in _IPA_GENERATING_RULES:
-      ipa_output_name = target.label.name
+    bundle_info = target[AppleBundleInfo]
 
-    artifacts = [x.path for x in target.files]
-    if ipa_output_name:
-      # Some targets produce more than one IPA or ZIP (e.g. ios_unit_test will
-      # generate two IPAs for the test and host bundles), we want to filter only
-      # exact matches to label name.
-      output_ipa = '/%s.ipa' % ipa_output_name
-      output_zip = '/%s.zip' % ipa_output_name
+    artifact = bundle_info.archive.path
+    archive_root = bundle_info.archive_root
 
-      artifacts = [x for x in artifacts if x.endswith(output_ipa)
-                   or x.endswith(output_zip)]
+    bundle_name = bundle_info.bundle_name
+    bundle_dir = bundle_info.bundle_dir
 
   # Collect generated files for bazel_build.py to copy under Tulsi root.
   all_files = depset()
@@ -924,7 +908,9 @@
     has_dsym = ctx.fragments.objc.generate_dsym
 
   info = _struct_omitting_none(
-      artifacts=artifacts,
+      artifact=artifact,
+      bundle_dir=bundle_dir,
+      archive_root=archive_root,
       generated_sources=[(x.path, x.short_path) for x in tulsi_generated_files],
       bundle_name=bundle_name,
       embedded_bundles=embedded_bundles.to_list(),
diff --git a/src/TulsiGenerator/Scripts/bazel_build.py b/src/TulsiGenerator/Scripts/bazel_build.py
index 9dc90ca..cb6c276 100755
--- a/src/TulsiGenerator/Scripts/bazel_build.py
+++ b/src/TulsiGenerator/Scripts/bazel_build.py
@@ -925,68 +925,108 @@
     """Installs Bazel-generated artifacts into the Xcode output directory."""
     xcode_artifact_path = self.artifact_output_path
 
-    if os.path.isdir(xcode_artifact_path):
-      try:
-        shutil.rmtree(xcode_artifact_path)
-      except OSError as e:
-        _PrintXcodeError('Failed to remove stale output directory ""%s". '
-                         '%s' % (xcode_artifact_path, e))
-        return 600
-    elif os.path.isfile(xcode_artifact_path):
-      try:
-        os.remove(xcode_artifact_path)
-      except OSError as e:
-        _PrintXcodeError('Failed to remove stale output file ""%s". '
-                         '%s' % (xcode_artifact_path, e))
-        return 600
-
     if not outputs_data:
       _PrintXcodeError('Failed to load top level output file.')
       return 600
 
     primary_output_data = outputs_data[0]
 
-    if 'artifacts' not in primary_output_data:
+    if 'artifact' not in primary_output_data:
       _PrintXcodeError(
           'Failed to find an output artifact for target %s in output map %r' %
           (xcode_artifact_path, primary_output_data))
       return 601
 
-    primary_artifact = primary_output_data['artifacts'][0]
+    primary_artifact = primary_output_data['artifact']
+    artifact_archive_root = primary_output_data.get('archive_root')
+    bundle_name = primary_output_data['bundle_name']
 
     # The PRODUCT_NAME used by the Xcode project is not trustable as it may be
     # modified by the user and, more importantly, may have been modified by
     # Tulsi to disambiguate multiple targets with the same name.
-    # To work around this, the product name is determined by dropping any
-    # extension from the primary artifact.
-    # TODO(abaire): Consider passing this value to the script explicitly.
-    self.bazel_product_name = os.path.splitext(
-        os.path.basename(primary_artifact))[0]
+    self.bazel_product_name = bundle_name
 
-    if primary_artifact.endswith('.ipa') or primary_artifact.endswith('.zip'):
-      bundle_name = primary_output_data.get('bundle_name')
-      exit_code = self._UnpackTarget(primary_artifact,
-                                     xcode_artifact_path,
-                                     bundle_name)
+    # We need to handle IPAs (from {ios, tvos}_application) differently from
+    # ZIPs (from the other bundled rules) because they output slightly different
+    # directory structures.
+    is_ipa = primary_artifact.endswith('.ipa')
+    is_zip = primary_artifact.endswith('.zip')
+
+    if is_ipa or is_zip:
+      expected_bundle_name = bundle_name + self.wrapper_suffix
+
+      # The directory structure within the IPA is then determined based on
+      # Bazel's package and/or product type.
+      if is_ipa:
+        bundle_subpath = os.path.join('Payload', expected_bundle_name)
+      else:
+        # If the artifact is a ZIP, assume that the bundle is the top-level
+        # directory (this is the way in which Skylark rules package artifacts
+        # that are not standalone IPAs).
+        bundle_subpath = expected_bundle_name
+
+      # Prefer to copy over files from the archive root instead of unzipping the
+      # ipa/zip in order to help preserve timestamps. Note that the archive root
+      # is only present for local builds; for remote builds we must extract from
+      # the zip file.
+      if self._IsValidArtifactArchiveRoot(artifact_archive_root, bundle_name):
+        source_location = os.path.join(artifact_archive_root, bundle_subpath)
+        exit_code = self._RsyncBundle(os.path.basename(primary_artifact),
+                                      source_location,
+                                      xcode_artifact_path)
+      else:
+        exit_code = self._UnpackTarget(primary_artifact,
+                                       xcode_artifact_path,
+                                       bundle_subpath)
       if exit_code:
         return exit_code
 
     elif os.path.isfile(primary_artifact):
+      # Remove the old artifact before copying.
+      if os.path.isfile(xcode_artifact_path):
+        try:
+          os.remove(xcode_artifact_path)
+        except OSError as e:
+          _PrintXcodeError('Failed to remove stale output file ""%s". '
+                           '%s' % (xcode_artifact_path, e))
+          return 600
       exit_code = self._CopyFile(os.path.basename(primary_artifact),
                                  primary_artifact,
                                  xcode_artifact_path)
       if exit_code:
         return exit_code
     else:
-      self._CopyBundle(os.path.basename(primary_artifact),
-                       primary_artifact,
-                       xcode_artifact_path)
+      self._InstallBundle(primary_artifact,
+                          xcode_artifact_path)
 
     # No return code check as this is not an essential operation.
     self._InstallEmbeddedBundlesIfNecessary(primary_output_data)
 
     return 0
 
+  def _IsValidArtifactArchiveRoot(self, archive_root, bundle_name):
+    """Returns true if the archive root is valid for use."""
+    if not archive_root or not os.path.isdir(archive_root):
+      return False
+
+    # The archive root will not be updated for any remote builds, but will be
+    # valid for local builds. We detect this by using an implementation detail
+    # of the rules_apple bundler: archives will always be transformed from
+    # <name>.unprocessed.zip (locally or remotely) to <name>.archive-root.
+    #
+    # Thus if the mod time on the archive root is not greater than the mod
+    # time on the on the zip, the archive root is not valid. Remote builds
+    # will end up copying the <name>.unprocessed.zip but not the
+    # <name>.archive-root, making this a valid temporary solution.
+    #
+    # In the future, it would be better to have this handled by the rules;
+    # until then this should suffice as a work around to improve build times.
+    unprocessed_zip = os.path.join(os.path.dirname(archive_root),
+                                   '%s.unprocessed.zip' % bundle_name)
+    if not os.path.isfile(unprocessed_zip):
+      return False
+    return os.path.getmtime(archive_root) > os.path.getmtime(unprocessed_zip)
+
   def _InstallEmbeddedBundlesIfNecessary(self, output_data):
     """Install embedded bundles next to the current target's output."""
 
@@ -1010,7 +1050,7 @@
       # is enough to make Instruments work.
       source_path = os.path.join(bundle_info['archive_root'], name)
       output_path = os.path.join(self.built_products_dir, name)
-      self._InstallBundle(source_path, output_path)
+      self._RsyncBundle(name, source_path, output_path)
 
     timer.End()
 
@@ -1077,6 +1117,31 @@
                                  output_path)
     return exit_code, output_path
 
+  def _RsyncBundle(self, source_path, full_source_path, output_path):
+    """Rsyncs the given bundle to the given expected output path."""
+    self._PrintVerbose('Copying %s to %s' % (source_path, output_path))
+
+    # rsync behavior changes based on presence of a trailing slash.
+    if not full_source_path.endswith('/'):
+      full_source_path += '/'
+
+    try:
+      # Use -c to check differences by checksum, -v for verbose,
+      # and --delete to delete stale files.
+      # The rest of the flags are the same as -a but without preserving
+      # timestamps, which is done intentionally so the timestamp will
+      # only change when the file is changed.
+      subprocess.check_output(['rsync',
+                               '-vcrlpgoD',
+                               '--delete',
+                               full_source_path,
+                               output_path],
+                              stderr=subprocess.STDOUT)
+    except subprocess.CalledProcessError as e:
+      _PrintXcodeError('Rsync failed. %s' % e)
+      return 650
+    return 0
+
   def _CopyBundle(self, source_path, full_source_path, output_path):
     """Copies the given bundle to the given expected output path."""
     self._PrintVerbose('Copying %s to %s' % (source_path, output_path))
@@ -1100,7 +1165,7 @@
       try:
         os.makedirs(output_path_dir)
       except OSError as e:
-        _PrintXcodeError('Failed to create output directory ""%s". '
+        _PrintXcodeError('Failed to create output directory "%s". '
                          '%s' % (output_path_dir, e))
         return 650
     try:
@@ -1113,7 +1178,7 @@
       return 650
     return 0
 
-  def _UnpackTarget(self, bundle_path, output_path, bundle_name):
+  def _UnpackTarget(self, bundle_path, output_path, bundle_subpath):
     """Unpacks generated bundle into the given expected output path."""
     self._PrintVerbose('Unpacking %s to %s' % (bundle_path, output_path))
 
@@ -1121,23 +1186,19 @@
       _PrintXcodeError('Generated bundle not found at "%s"' % bundle_path)
       return 670
 
-    # We need to handle IPAs (from the native rules) differently from ZIPs
-    # (from the Skylark rules) because they output slightly different directory
-    # structures.
+    if os.path.isdir(output_path):
+      try:
+        shutil.rmtree(output_path)
+      except OSError as e:
+        _PrintXcodeError('Failed to remove stale output directory ""%s". '
+                         '%s' % (output_path, e))
+        return 600
+
+    # We need to handle IPAs (from {ios, tvos}_application) differently from
+    # ZIPs (from the other bundled rules) because they output slightly different
+    # directory structures.
     is_ipa = bundle_path.endswith('.ipa')
 
-    expected_bundle_name = bundle_name + self.wrapper_suffix
-
-    # The directory structure within the IPA is then determined based on Bazel's
-    # package and/or product type.
-    if is_ipa:
-      expected_bundle_subpath = os.path.join('Payload', expected_bundle_name)
-    else:
-      # If the artifact is a ZIP, assume that the bundle is the top-level
-      # directory (this is the way in which Skylark rules package artifacts
-      # that are not standalone IPAs).
-      expected_bundle_subpath = expected_bundle_name
-
     with zipfile.ZipFile(bundle_path, 'r') as zf:
       for item in zf.infolist():
         filename = item.filename
@@ -1148,18 +1209,18 @@
         if basedir.endswith('Support') or basedir.endswith('Support2'):
           continue
 
-        if len(filename) < len(expected_bundle_subpath):
+        if len(filename) < len(bundle_subpath):
           continue
 
         attributes = (item.external_attr >> 16) & 0o777
         self._PrintVerbose('Extracting %s (%o)' % (filename, attributes),
                            level=1)
 
-        if not filename.startswith(expected_bundle_subpath):
+        if not filename.startswith(bundle_subpath):
           # TODO(abaire): Make an error if Bazel modifies this behavior.
           _PrintXcodeWarning('Mismatched extraction path. Bundle content '
                              'at "%s" expected to have subpath of "%s"' %
-                             (filename, expected_bundle_subpath))
+                             (filename, bundle_subpath))
 
         dir_components = self._SplitPathComponents(filename)