Files
firebase-ios-sdk/scripts/sync_project.rb
T
Gil 12fb9f61d2 Upgrade gRPC to 1.24.3; upgrade protobuf to 3.9.2 (#4252)
* Upgrade gRPC to 1.24.3

* Upgrade C++ Protobuf to 3.9.2

Disable -Wunreachable-code in protobuf-generated code

* Regenerate C++ protos with protobuf 3.9.2

* Generate objc protos with Protobuf 3.9.2
2019-11-07 15:23:14 -08:00

868 lines
23 KiB
Ruby
Executable File

#!/usr/bin/ruby
# Copyright 2018 Google
#
# 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
#
# http://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.
# Syncs Xcode project folder and target structure with the filesystem. This
# script finds all files on the filesystem that match the patterns supplied
# below and changes the project to match what it found.
#
# Run this script after adding/removing tests to keep the project in sync.
require 'cocoapods'
require 'optparse'
require 'pathname'
# Note that xcodeproj 1.5.8 appears to be broken
# https://github.com/CocoaPods/Xcodeproj/issues/572
gem 'xcodeproj', '!= 1.5.8'
require 'xcodeproj'
ROOT_DIR = Pathname.new(__FILE__).dirname().join('..').expand_path()
PODFILE_DIR = ROOT_DIR.join('Firestore', 'Example')
def main()
test_only = false
OptionParser.new do |opts|
opts.on('--test-only', 'Check diffs without writing') do |v|
test_only = v
end
end.parse!
# Make all filenames relative to the project root.
Dir.chdir(ROOT_DIR.to_s)
changes = sync_firestore(test_only)
status = test_only && changes > 0 ? 2 : 0
exit(status)
end
# Make it so that you can "add" hash literals together by merging their
# contents.
class Hash
def +(other)
return merge(other)
end
end
def sync_firestore(test_only)
project = Xcodeproj::Project.open('Firestore/Example/Firestore.xcodeproj')
spec = Pod::Spec.from_file('FirebaseFirestore.podspec')
swift_spec = Pod::Spec.from_file('FirebaseFirestoreSwift.podspec')
# Enable warnings after opening the project to avoid the warnings in
# xcodeproj itself
$VERBOSE = true
s = Syncer.new(project, ROOT_DIR)
# Files on the filesystem that should be ignored.
s.ignore_files = [
'CMakeLists.txt',
'README.md',
'InfoPlist.strings',
'*.orig',
'*.plist',
'.*',
]
# Folder groups in the Xcode project that contain tests.
s.groups = [
'Tests',
'CoreTests',
'CoreTestsProtos',
'SwiftTests',
]
# Copy key settings from the podspec
podspec_settings = [
'CLANG_CXX_LANGUAGE_STANDARD',
'GCC_C_LANGUAGE_STANDARD',
]
xcconfig_spec = spec.attributes_hash['pod_target_xcconfig'].dup
xcconfig_spec.select! { |k, v| podspec_settings.include?(k) }
# Settings for all Objective-C/C++ targets
xcconfig_objc = xcconfig_spec + {
'INFOPLIST_FILE' => '"${SRCROOT}/Tests/Tests-Info.plist"',
# Duplicate the header search paths from the main podspec because they're
# phrased in terms of PODS_TARGET_SRCROOT, which isn't defined for other
# targets.
'HEADER_SEARCH_PATHS' => [
# Include fully qualified from the root of the repo
'"${PODS_ROOT}/../../.."',
# Make public headers available as "FIRQuery.h"
'"${PODS_ROOT}/../../../Firestore/Source/Public"',
# Generated protobuf and nanopb output expects to search relative to the
# output path.
'"${PODS_ROOT}/../../../Firestore/Protos/cpp"',
'"${PODS_ROOT}/../../../Firestore/Protos/nanopb"',
# Other dependencies that assume #includes are relative to their roots.
'"${PODS_ROOT}/../../../Firestore/third_party/abseil-cpp"',
'"${PODS_ROOT}/GoogleBenchmark/include"',
'"${PODS_ROOT}/GoogleTest/googlemock/include"',
'"${PODS_ROOT}/GoogleTest/googletest/include"',
'"${PODS_ROOT}/leveldb-library/include"',
],
'SYSTEM_HEADER_SEARCH_PATHS' => [
# Nanopb wants to #include <pb.h>
'"${PODS_ROOT}/nanopb"',
# Protobuf wants to #include <google/protobuf/stubs/common.h>
'"${PODS_ROOT}/ProtobufCpp/src"',
],
'OTHER_CFLAGS' => [
# Protobuf C++ generates dead code.
'-Wno-unreachable-code',
# Our public build can't include -Werror, but for development it's quite
# helpful.
'-Werror'
]
}
xcconfig_swift = {
'SWIFT_OBJC_BRIDGING_HEADER' =>
'${PODS_ROOT}/../../../Firestore/Swift/Tests/BridgingHeader.h',
'SWIFT_VERSION' => pick_swift_version(swift_spec),
}
['iOS', 'macOS', 'tvOS'].each do |platform|
s.target "Firestore_Example_#{platform}" do |t|
t.xcconfig = xcconfig_objc + xcconfig_swift + {
# Passing -all_load is required to get all our C++ code into the test
# host.
#
# Normally when running tests, the test target contains only the tests
# proper, and links against the test host for the code under test. The
# test host doesn't do anything though, so the linker strips C++-only
# object code away.
#
# This is particular to C++ because by default CocoaPods configures the
# test host to link with the -ObjC flag. This causes the linker to pull
# in all Objective-C object code. -all_load fixes this by forcing the
# linker to pull in everything.
'OTHER_LDFLAGS' => '-all_load',
}
end
s.target "Firestore_Tests_#{platform}" do |t|
t.source_files = [
'Firestore/Example/Tests/**',
'Firestore/core/test/**',
'Firestore/Protos/cpp/**',
]
t.exclude_files = [
# needs to be in project but not in target
'Firestore/Example/Tests/Tests-Info.plist',
# These files are integration tests, handled below
'Firestore/Example/Tests/Integration/**',
]
t.xcconfig = xcconfig_objc + xcconfig_swift
end
end
['iOS', 'macOS', 'tvOS'].each do |platform|
s.target "Firestore_IntegrationTests_#{platform}" do |t|
t.source_files = [
'Firestore/Example/Tests/**',
'Firestore/Protos/cpp/**',
'Firestore/Swift/Tests/**',
'Firestore/core/test/**',
]
t.exclude_files = [
# needs to be in project but not in target
'Firestore/Example/Tests/Tests-Info.plist',
]
t.xcconfig = xcconfig_objc + xcconfig_swift
end
s.target 'Firestore_Benchmarks_iOS' do |t|
t.xcconfig = xcconfig_objc + {
'INFOPLIST_FILE' => '${SRCROOT}/Benchmarks/Info.plist',
}
end
s.target 'Firestore_FuzzTests_iOS' do |t|
t.xcconfig = xcconfig_objc + {
'INFOPLIST_FILE' =>
'${SRCROOT}/FuzzTests/Firestore_FuzzTests_iOS-Info.plist',
'OTHER_CFLAGS' => [
'-fsanitize=fuzzer',
]
}
end
s.target 'Firestore_SwiftTests_iOS' do |t|
t.xcconfig = xcconfig_objc + xcconfig_swift
end
end
changes = s.sync(test_only)
if not test_only
sort_project(project)
if project.dirty?
project.save()
end
end
return changes
end
# Picks a swift version to use from a podspec's swift_versions
def pick_swift_version(spec)
versions = spec.attributes_hash['swift_versions']
if versions.is_a?(Array)
return versions[-1]
end
return versions
end
# A list of filesystem patterns
class PatternList
def initialize()
@patterns = []
end
attr_accessor :patterns
# Evaluates the rel_path against the given list of fnmatch patterns.
def matches?(rel_path)
@patterns.each do |pattern|
if rel_path.fnmatch?(pattern)
return true
end
end
return false
end
end
# The definition of a test target including the target name, its source_files
# and exclude_files. A file is considered part of a target if it matches a
# pattern in source_files but does not match a pattern in exclude_files.
class TargetDef
def initialize(name)
@name = name
@sync_sources = false
@source_files = PatternList.new()
@exclude_files = PatternList.new()
@xcconfig = {}
end
attr_reader :name, :sync_sources, :source_files, :exclude_files
attr_accessor :xcconfig
def source_files=(value)
@sync_sources = true
@source_files.patterns.replace(value)
end
def exclude_files=(value)
@exclude_files.patterns.replace(value)
end
# Returns true if the given rel_path matches this target's source_files
# but not its exclude_files.
#
# Args:
# - rel_path: a Pathname instance with a path relative to the project root.
def matches?(rel_path)
return @source_files.matches?(rel_path) && !@exclude_files.matches?(rel_path)
end
def diff(project_files, target)
diff = Diff.new
project_files.each do |file_ref|
if matches?(relative_path(file_ref))
entry = diff.track(file_ref.real_path)
entry.in_source = true
entry.ref = file_ref
end
end
each_target_file(target) do |file_ref|
entry = diff.track(file_ref.real_path)
entry.in_target = true
entry.ref = file_ref
end
return diff
end
# We're only managing synchronization of files in these phases.
INTERESTING_PHASES = [
Xcodeproj::Project::Object::PBXHeadersBuildPhase,
Xcodeproj::Project::Object::PBXSourcesBuildPhase,
Xcodeproj::Project::Object::PBXResourcesBuildPhase,
]
# Finds all the files referred to by any phase in a target
def each_target_file(target)
target.build_phases.each do |phase|
next if not INTERESTING_PHASES.include?(phase.class)
phase.files.each do |build_file|
yield build_file.file_ref
end
end
end
end
class Syncer
HEADERS = %w{.h}
SOURCES = %w{.c .cc .m .mm .swift}
def initialize(project, root_dir)
@project = project
@finder = DirectoryLister.new(root_dir)
@groups = []
@targets = []
@seen_groups = {}
end
# Considers the given fnmatch glob patterns to be ignored by the syncer.
# Patterns are matched both against the basename and project-relative
# qualified pathname.
def ignore_files=(patterns)
@finder.add_patterns(patterns)
end
# Names the groups within the project that serve as roots for tests within
# the project.
def groups=(groups)
@groups = []
groups.each do |group|
project_group = @project[group]
if project_group.nil?
raise "Project does not contain group #{group}"
end
@groups.push(@project[group])
end
end
# Starts a new target block. Creates a new TargetDef and yields it.
def target(name, &block)
t = TargetDef.new(name)
@targets.push(t)
block.call(t)
end
# Finds the target definition with the given name.
def find_target(name)
@targets.each do |target|
if target.name == name
return target
end
end
return nil
end
# Synchronizes the filesystem with the project.
#
# Generally there are three separate ways a file is referenced within a project:
#
# 1. The file must be in the global list of files, assigning it a UUID.
# 2. The file must be added to folder groups, describing where it is in the
# folder view of the Project Navigator.
# 3. The file must be added to a target phase describing how it's built.
#
# The Xcodeproj library handles (1) for us automatically if we do (2).
#
# Returns the number of changes made during synchronization.
def sync(test_only = false)
# Figure the diff between the filesystem and the group structure
group_differ = GroupDiffer.new(@finder)
group_diff = group_differ.diff(@groups)
changes = group_diff.changes
to_remove = group_diff.to_remove
# Add all files first, to ensure they exist for later steps
add_to_project(group_diff.to_add)
project_files = find_project_files_after_removal(@project.files, to_remove)
@project.native_targets.each do |target|
target_def = find_target(target.name)
next if target_def.nil?
if target_def.sync_sources
target_diff = target_def.diff(project_files, target)
target_diff.sorted_entries.each do |entry|
changes += sync_target_entry(target, entry)
end
end
if not test_only
# Don't sync xcconfig changes in test-only mode.
sync_xcconfig(target_def, target)
end
end
remove_from_project(to_remove)
return changes
end
private
def find_project_files_after_removal(files, to_remove)
remove_paths = Set.new()
to_remove.each do |entry|
remove_paths.add(entry.path)
end
result = []
files.each do |file_ref|
next if file_ref.source_tree != '<group>'
next if remove_paths.include?(file_ref.real_path)
path = file_ref.real_path
next if @finder.ignore_basename?(path.basename)
next if @finder.ignore_pathname?(path)
result.push(file_ref)
end
return result
end
# Adds the given file to the project, in a path starting from the test root
# that fully prefixes the file.
def add_to_project(to_add)
to_add.each do |entry|
path = entry.path
root_group = find_group_containing(path)
# Find or create the group to contain the path.
dir_rel_path = path.relative_path_from(root_group.real_path).dirname
group = root_group.find_subpath(dir_rel_path.to_s, true)
file_ref = group.new_file(path.to_s)
ext = path.extname
entry.ref = file_ref
end
end
# Finds a group whose path prefixes the given entry. Starting from the
# project root may not work since not all directories exist within the
# example app.
def find_group_containing(path)
@groups.each do |group|
rel = path.relative_path_from(group.real_path)
next if rel.to_s.start_with?('..')
return group
end
raise "Could not find an existing group that's a parent of #{entry.path}"
end
# Removes the given file references from the project after the file is found
# to not exist on the filesystem but references to it still exist in the
# project.
def remove_from_project(to_remove)
to_remove.each do |entry|
file_ref = entry.ref
file_ref.remove_from_project
end
end
# Syncs a single build file for a given phase. Returns the number of changes
# made.
def sync_target_entry(target, entry)
return 0 if entry.unchanged?
phase = find_phase(target, entry.path)
return 0 if phase.nil?
mark_change_in_group(target.display_name)
if entry.to_add?
printf(" %s - added\n", basename(entry.ref))
phase.add_file_reference(entry.ref)
else
printf(" %s - removed\n", basename(entry.ref))
phase.remove_file_reference(entry.ref)
end
return 1
end
# Finds the phase to which the given pathname belongs based on its file
# extension.
#
# Returns nil if the path does not belong in any phase.
def find_phase(target, path)
path = normalize_to_pathname(path)
ext = path.extname
if SOURCES.include?(ext)
return target.source_build_phase
elsif HEADERS.include?(ext)
# TODO(wilhuff): sync headers
#return target.headers_build_phase
return nil
else
return target.resources_build_phase
end
end
# Syncs build settings to the .xcconfig file for the build configuration,
# avoiding any changes to the Xcode project file.
def sync_xcconfig(target_def, target)
dirty = false
target.build_configurations.each do |config|
requested = flatten(target_def.xcconfig)
if config.base_configuration_reference.nil?
# Running pod install with PLATFORM set to something other than "all"
# ends up removing baseConfigurationReference entries from the project
# file. Skip these entries when re-running.
puts "Skipping #{target.name} (#{config.name})"
next
end
path = PODFILE_DIR.join(config.base_configuration_reference.real_path)
if !File.file?(path)
puts "Skipping #{target.name} (#{config.name}); missing xcconfig"
next
end
contents = Xcodeproj::Config.new(path)
contents.merge!(requested)
contents.save_as(path)
end
end
# Converts a hash of lists to a flat hash of strings.
def flatten(xcconfig)
result = {}
xcconfig.each do |key, value|
if value.is_a?(Array)
value = value.join(' ')
end
result[key] = value
end
return result
end
end
def normalize_to_pathname(file_ref)
if !file_ref.is_a? Pathname
if file_ref.is_a? String
file_ref = Pathname.new(file_ref)
else
file_ref = file_ref.real_path
end
end
return file_ref
end
def basename(file_ref)
return normalize_to_pathname(file_ref).basename
end
def relative_path(file_ref)
path = normalize_to_pathname(file_ref)
return path.relative_path_from(ROOT_DIR)
end
def mark_change_in_group(group)
path = group.to_s
if !@seen_groups.has_key?(path)
puts "#{path} ..."
@seen_groups[path] = true
end
end
def sort_project(project)
project.groups.each do |group|
sort_group(group)
end
project.targets.each do |target|
target.build_phases.each do |phase|
phase.files.sort! { |a, b|
a.file_ref.real_path.basename <=> b.file_ref.real_path.basename
}
end
end
end
def sort_group(group)
group.groups.each do |child|
sort_group(child)
end
group.children.sort! do |a, b|
# Sort groups first
if a.isa == 'PBXGroup' && b.isa != 'PBXGroup'
-1
elsif a.isa != 'PBXGroup' && b.isa == 'PBXGroup'
1
elsif a.display_name && b.display_name
File.basename(a.display_name) <=> File.basename(b.display_name)
else
0
end
end
end
# Tracks how a file is referenced: in the project file, on the filesystem,
# neither, or both.
class DiffEntry
def initialize(path)
@path = path
@in_source = false
@in_target = false
@ref = nil
end
attr_reader :path
attr_accessor :in_source, :in_target, :ref
def unchanged?()
return @in_source && @in_target
end
def to_add?()
return @in_source && !@in_target
end
def to_remove?()
return !@in_source && @in_target
end
end
# A set of differences between some source and a target.
class Diff
def initialize()
@entries = {}
end
attr_accessor :entries
def track(path)
if @entries.has_key?(path)
return @entries[path]
end
entry = DiffEntry.new(path)
@entries[path] = entry
return entry
end
# Returns a list of entries that are to be added to the target
def to_add()
return @entries.values.select { |entry| entry.to_add? }
end
# Returns a list of entries that are to be removed to the target
def to_remove()
return @entries.values.select { |entry| entry.to_remove? }
end
# Returns a list of entries in sorted order.
def sorted_entries()
return @entries.values.sort { |a, b| a.path.basename <=> b.path.basename }
end
def changes()
return @entries.values.count { |entry| entry.to_add? || entry.to_remove? }
end
end
# Diffs folder groups against the filesystem directories referenced by those
# folder groups.
#
# Folder groups in the project may each refer to an arbitrary path, so
# traversing from a parent group to a subgroup may jump to a radically
# different filesystem location or alias a previously processed directory.
#
# This class performs a diff by essentially tracking only whether or not a
# given absolute path has been seen in either the filesystem or the group
# structure, without paying attention to where in the group structure the file
# reference actually occurs.
#
# This helps ensure that the default arbitrary splits in group structure are
# preserved. For example, "Supporting Files" is an alias for the same directory
# as the parent group, and Apple's default project setup hides some files in
# "Supporting Files". The approach this diff takes preserves this arrangement
# without understanding specifically which files should be hidden and which
# should exist in the parent.
#
# However, this approach has limitations: removing a file from "Supporting
# Files" will be handled, but re-adding the file is likely to add it to the
# group that mirrors the filesystem hierarchy rather than back into its
# original position. So far this approach has been acceptable because there's
# nothing of value in these aliasing folders. Should this change we'll have to
# revisit.
class GroupDiffer
def initialize(dir_lister)
@dir_lister = dir_lister
@dirs = {}
@diff = Diff.new()
end
# Finds all files on the filesystem contained within the paths of the given
# groups and computes a list of DiffEntries describing the state of the
# files.
#
# Args:
# - groups: A list of PBXGroup objects representing folder groups within the
# project that contain files of interest.
#
# Returns:
# A hash of Pathname to DiffEntry objects, one for each file found. If the
# file exists on the filesystem, :in_source will be true. If the file exists
# in the project :in_target will be true and :ref will be set to the
# PBXFileReference naming the file.
def diff(groups)
groups.each do |group|
diff_project_files(group)
end
return @diff
end
private
# Recursively traverses all the folder groups in the Xcode project and finds
# files both on the filesystem and the group file listing.
def diff_project_files(group)
find_fs_files(group.real_path)
group.groups.each do |child|
diff_project_files(child)
end
group.files.each do |file_ref|
path = file_ref.real_path
entry = @diff.track(path)
entry.in_target = true
entry.ref = file_ref
if path.file?
entry.in_source = true
end
end
end
def find_fs_files(parent_path)
# Avoid re-traversing the filesystem
if @dirs.has_key?(parent_path)
return
end
@dirs[parent_path] = true
@dir_lister.entries(parent_path).each do |path|
if path.directory?
find_fs_files(path)
next
end
entry = @diff.track(path)
entry.in_source = true
end
end
end
# Finds files on the filesystem while ignoring files that have been declared to
# be ignored.
class DirectoryLister
def initialize(root_dir)
@root_dir = root_dir
@ignore_basenames = ['.', '..']
@ignore_pathnames = []
end
def add_patterns(patterns)
patterns.each do |pattern|
if File.basename(pattern) != pattern
@ignore_pathnames.push(File.join(@root_dir, pattern))
else
@ignore_basenames.push(pattern)
end
end
end
# Finds filesystem entries that are immediate children of the given Pathname,
# ignoring files that match the global ignore_files patterns.
def entries(path)
result = []
return result if not path.exist?
path.entries.each do |entry|
next if ignore_basename?(entry)
file = path.join(entry)
next if ignore_pathname?(file)
result.push(file)
end
return result
end
def ignore_basename?(basename)
@ignore_basenames.each do |ignore|
if basename.fnmatch(ignore)
return true
end
end
return false
end
def ignore_pathname?(file)
@ignore_pathnames.each do |ignore|
if file.fnmatch(ignore)
return true
end
end
return false
end
end
if __FILE__ == $0
main()
end