sync_project.rb 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. #!/usr/bin/ruby
  2. # Copyright 2018 Google
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. # Syncs Xcode project folder and target structure with the filesystem. This
  16. # script finds all files on the filesystem that match the patterns supplied
  17. # below and changes the project to match what it found.
  18. #
  19. # Run this script after adding/removing tests to keep the project in sync.
  20. require 'pathname'
  21. # Note that xcodeproj 1.5.8 appears to be broken
  22. # https://github.com/CocoaPods/Xcodeproj/issues/572
  23. gem 'xcodeproj', '!= 1.5.8'
  24. require 'xcodeproj'
  25. def main()
  26. # Make all filenames relative to the project root.
  27. Dir.chdir(File.join(File.dirname(__FILE__), '..'))
  28. sync_firestore()
  29. end
  30. def sync_firestore()
  31. project = Xcodeproj::Project.open('Firestore/Example/Firestore.xcodeproj')
  32. # Enable warnings after opening the project to avoid the warnings in
  33. # xcodeproj itself
  34. $VERBOSE = true
  35. s = Syncer.new(project, Dir.pwd)
  36. # Files on the filesystem that should be ignored.
  37. s.ignore_files = [
  38. 'CMakeLists.txt',
  39. 'InfoPlist.strings',
  40. '*.orig',
  41. '*.plist',
  42. '.*',
  43. ]
  44. # Folder groups in the Xcode project that contain tests.
  45. s.test_groups = [
  46. 'Tests',
  47. 'CoreTests',
  48. 'CoreTestsProtos',
  49. 'SwiftTests',
  50. ]
  51. s.target 'Firestore_Tests_iOS' do |t|
  52. t.source_files = [
  53. 'Firestore/Example/Tests/**',
  54. 'Firestore/core/test/**',
  55. 'Firestore/Protos/cpp/**',
  56. 'Firestore/third_party/Immutable/Tests/**',
  57. ]
  58. t.exclude_files = [
  59. # needs to be in project but not in target
  60. 'Firestore/Example/Tests/Tests-Info.plist',
  61. # These files are integration tests, handled below
  62. 'Firestore/Example/Tests/Integration/**',
  63. ]
  64. end
  65. s.target 'Firestore_IntegrationTests_iOS' do |t|
  66. t.source_files = [
  67. 'Firestore/Example/Tests/Integration/**',
  68. 'Firestore/Example/Tests/Util/FSTEventAccumulator.mm',
  69. 'Firestore/Example/Tests/Util/FSTHelpers.mm',
  70. 'Firestore/Example/Tests/Util/FSTIntegrationTestCase.mm',
  71. 'Firestore/Example/Tests/Util/XCTestCase+Await.mm',
  72. 'Firestore/Example/Tests/en.lproj/InfoPlist.strings',
  73. 'Firestore/core/test/firebase/firestore/testutil/**',
  74. ]
  75. end
  76. s.sync()
  77. sort_project(project)
  78. if project.dirty?
  79. project.save()
  80. end
  81. end
  82. # The definition of a test target including the target name, its source_files
  83. # and exclude_files. A file is considered part of a target if it matches a
  84. # pattern in source_files but does not match a pattern in exclude_files.
  85. class TargetDef
  86. def initialize(name)
  87. @name = name
  88. @source_files = []
  89. @exclude_files = []
  90. end
  91. attr_accessor :name, :source_files, :exclude_files
  92. # Returns true if the given relative_path matches this target's source_files
  93. # but not its exclude_files.
  94. #
  95. # Args:
  96. # - relative_path: a Pathname instance with a path relative to the project
  97. # root.
  98. def matches?(relative_path)
  99. return matches_patterns(relative_path, @source_files) &&
  100. !matches_patterns(relative_path, @exclude_files)
  101. end
  102. private
  103. # Evaluates the relative_path against the given list of fnmatch patterns.
  104. def matches_patterns(relative_path, patterns)
  105. patterns.each do |pattern|
  106. if relative_path.fnmatch?(pattern)
  107. return true
  108. end
  109. end
  110. return false
  111. end
  112. end
  113. class Syncer
  114. def initialize(project, root_dir)
  115. @project = project
  116. @root_dir = Pathname.new(root_dir)
  117. @finder = DirectoryLister.new(@root_dir)
  118. @seen_groups = {}
  119. @test_groups = []
  120. @targets = []
  121. end
  122. # Considers the given fnmatch glob patterns to be ignored by the syncer.
  123. # Patterns are matched both against the basename and project-relative
  124. # qualified pathname.
  125. def ignore_files=(patterns)
  126. @finder.add_patterns(patterns)
  127. end
  128. # Names the groups within the project that serve as roots for tests within
  129. # the project.
  130. def test_groups=(groups)
  131. @test_groups = []
  132. groups.each do |group|
  133. project_group = @project[group]
  134. if project_group.nil?
  135. raise "Project does not contain group #{group}"
  136. end
  137. @test_groups.push(@project[group])
  138. end
  139. end
  140. # Starts a new target block. Creates a new TargetDef and yields it.
  141. def target(name, &block)
  142. t = TargetDef.new(name)
  143. @targets.push(t)
  144. block.call(t)
  145. end
  146. # Synchronizes the filesystem with the project.
  147. #
  148. # Generally there are three separate ways a file is referenced within a project:
  149. #
  150. # 1. The file must be in the global list of files, assigning it a UUID.
  151. # 2. The file must be added to folder groups, describing where it is in the
  152. # folder view of the Project Navigator.
  153. # 3. The file must be added to a target describing how it's built.
  154. #
  155. # The Xcodeproj library handles (1) for us automatically if we do (2).
  156. #
  157. # Synchronization essentially proceeds in two steps:
  158. #
  159. # 1. Sync the filesystem structure with the folder group structure. This has
  160. # the effect of bringing (1) and (2) into sync.
  161. # 2. Sync the global list of files with the targets.
  162. def sync()
  163. group_differ = GroupDiffer.new(@finder)
  164. group_diffs = group_differ.diff(@test_groups)
  165. sync_groups(group_diffs)
  166. @targets.each do |target_def|
  167. sync_target(target_def)
  168. end
  169. end
  170. private
  171. def sync_groups(diff_entries)
  172. diff_entries.each do |entry|
  173. if !entry.in_source && entry.in_target
  174. remove_from_project(entry.ref)
  175. end
  176. if entry.in_source && !entry.in_target
  177. add_to_project(entry.path)
  178. end
  179. end
  180. end
  181. # Removes the given file reference from the project after the file is found
  182. # missing but references to it still exist in the project.
  183. def remove_from_project(file_ref)
  184. group = file_ref.parents[-1]
  185. mark_change_in_group(relative_path(group))
  186. puts " #{basename(file_ref)} - removed"
  187. # If the file is gone, any build phase that refers to must also remove the
  188. # file. Without this, the project will have build file references that
  189. # contain no actual file.
  190. @project.native_targets.each do |target|
  191. target.build_phases.each do |phase|
  192. if phase.include?(file_ref)
  193. phase.remove_file_reference(file_ref)
  194. end
  195. end
  196. end
  197. file_ref.remove_from_project
  198. end
  199. # Adds the given file to the project, in a path starting from the test root
  200. # that fully prefixes the file.
  201. def add_to_project(path)
  202. root_group = find_test_group_containing(path)
  203. # Find or create the group to contain the path.
  204. dir_rel_path = path.relative_path_from(root_group.real_path).dirname
  205. group = root_group.find_subpath(dir_rel_path.to_s, true)
  206. mark_change_in_group(relative_path(group))
  207. file_ref = group.new_file(path.to_s)
  208. puts " #{basename(file_ref)} - added"
  209. return file_ref
  210. end
  211. # Finds a test group whose path prefixes the given entry. Starting from the
  212. # project root may not work since not all test directories exist within the
  213. # example app.
  214. def find_test_group_containing(path)
  215. @test_groups.each do |group|
  216. rel = path.relative_path_from(group.real_path)
  217. next if rel.to_s.start_with?('..')
  218. return group
  219. end
  220. raise "Could not find an existing test group that's a parent of #{entry.path}"
  221. end
  222. def mark_change_in_group(group)
  223. path = group.to_s
  224. if !@seen_groups.has_key?(path)
  225. puts "#{path} ..."
  226. @seen_groups[path] = true
  227. end
  228. end
  229. SOURCES = %w{.c .cc .m .mm}
  230. def sync_target(target_def)
  231. target = @project.native_targets.find { |t| t.name == target_def.name }
  232. if !target
  233. raise "Missing target #{target_def.name}"
  234. end
  235. files = find_files_for_target(target_def)
  236. sources, resources = classify_files(files)
  237. sync_build_phase(target, target.source_build_phase, sources)
  238. end
  239. def classify_files(files)
  240. sources = {}
  241. resources = {}
  242. files.each do |file|
  243. path = file.real_path
  244. ext = path.extname
  245. if SOURCES.include?(ext)
  246. sources[path] = file
  247. end
  248. end
  249. return sources, resources
  250. end
  251. def sync_build_phase(target, phase, sources)
  252. # buffer changes to the phase to avoid modifying the array we're iterating
  253. # over.
  254. to_remove = []
  255. phase.files.each do |build_file|
  256. source_path = build_file.file_ref.real_path
  257. if sources.has_key?(source_path)
  258. # matches spec and existing target no action taken
  259. sources.delete(source_path)
  260. else
  261. # in the phase but now missing in the groups
  262. to_remove.push(build_file)
  263. end
  264. end
  265. to_remove.each do |build_file|
  266. mark_change_in_group(target.name)
  267. source_path = build_file.file_ref.real_path
  268. puts " #{relative_path(source_path)} - removed"
  269. phase.remove_build_file(build_file)
  270. end
  271. sources.each do |path, file_ref|
  272. mark_change_in_group(target.name)
  273. phase.add_file_reference(file_ref)
  274. puts " #{relative_path(file_ref)} - added"
  275. end
  276. end
  277. def find_files_for_target(target_def)
  278. result = []
  279. @project.files.each do |file_ref|
  280. next if file_ref.source_tree != '<group>'
  281. rel = relative_path(file_ref)
  282. if target_def.matches?(rel)
  283. result.push(file_ref)
  284. end
  285. end
  286. return result
  287. end
  288. def normalize_to_pathname(file_ref)
  289. if !file_ref.is_a? Pathname
  290. if file_ref.is_a? String
  291. file_ref = Pathname.new(file_ref)
  292. else
  293. file_ref = file_ref.real_path
  294. end
  295. end
  296. return file_ref
  297. end
  298. def basename(file_ref)
  299. return normalize_to_pathname(file_ref).basename
  300. end
  301. def relative_path(file_ref)
  302. file_ref = normalize_to_pathname(file_ref)
  303. return file_ref.relative_path_from(@root_dir)
  304. end
  305. end
  306. def sort_project(project)
  307. project.groups.each do |group|
  308. sort_group(group)
  309. end
  310. project.targets.each do |target|
  311. target.build_phases.each do |phase|
  312. phase.files.sort! { |a, b|
  313. a.file_ref.real_path.basename <=> b.file_ref.real_path.basename
  314. }
  315. end
  316. end
  317. end
  318. def sort_group(group)
  319. group.groups.each do |child|
  320. sort_group(child)
  321. end
  322. group.children.sort! do |a, b|
  323. # Sort groups first
  324. if a.isa == 'PBXGroup' && b.isa != 'PBXGroup'
  325. -1
  326. elsif a.isa != 'PBXGroup' && b.isa == 'PBXGroup'
  327. 1
  328. elsif a.display_name && b.display_name
  329. File.basename(a.display_name) <=> File.basename(b.display_name)
  330. else
  331. 0
  332. end
  333. end
  334. end
  335. # Tracks how a file is referenced: in the project file, on the filesystem,
  336. # neither, or both.
  337. class DiffEntry
  338. def initialize(path)
  339. @path = path
  340. @in_source = false
  341. @in_target = false
  342. @ref = nil
  343. end
  344. attr_reader :path
  345. attr_accessor :in_source, :in_target, :ref
  346. end
  347. # Diffs folder groups against the filesystem directories referenced by those
  348. # folder groups.
  349. #
  350. # This performs the diff starting from the directories referenced by the test
  351. # groups in the project, finding files contained within them. When comparing
  352. # the files it finds against the project this acts on absolute paths to avoid
  353. # problems with arbitrary additional groupings in project structure that are
  354. # standard, e.g. "Supporting Files" or "en.lproj" which either act as aliases
  355. # for the parent or are folders that are omitted from the project view.
  356. # Processing the diff this way allows these warts to be tolerated, even if they
  357. # won't necessarily be recreated if an artifact is added to the filesystem.
  358. class GroupDiffer
  359. def initialize(dir_lister)
  360. @dir_lister = dir_lister
  361. @entries = {}
  362. @dirs = {}
  363. end
  364. # Finds all tests on the filesystem contained within the paths of the given
  365. # test groups and computes a list of DiffEntries describing the state of the
  366. # files.
  367. #
  368. # Args:
  369. # - groups: A list of PBXGroup objects representing folder groups within the
  370. # project that contain tests.
  371. #
  372. # Returns:
  373. # A list of DiffEntry objects, one for each test found. If the test exists on
  374. # the filesystem, :in_source will be true. If the test exists in the project
  375. # :in_target will be true and :ref will be set to the PBXFileReference naming
  376. # the file.
  377. def diff(groups) groups.each do |group| diff_project_files(group) end
  378. return @entries.values.sort { |a, b| a.path.basename <=> b.path.basename }
  379. end
  380. private
  381. # Recursively traverses all the folder groups in the Xcode project and finds
  382. # files both on the filesystem and the group file listing.
  383. def diff_project_files(group)
  384. find_fs_files(group.real_path)
  385. group.groups.each do |child|
  386. diff_project_files(child)
  387. end
  388. group.files.each do |file_ref|
  389. path = file_ref.real_path
  390. entry = track_file(path)
  391. entry.in_target = true
  392. entry.ref = file_ref
  393. if path.file?
  394. entry.in_source = true
  395. end
  396. end
  397. end
  398. def find_fs_files(parent_path)
  399. # Avoid re-traversing the filesystem
  400. if @dirs.has_key?(parent_path)
  401. return
  402. end
  403. @dirs[parent_path] = true
  404. @dir_lister.entries(parent_path).each do |path|
  405. if path.directory?
  406. find_fs_files(path)
  407. next
  408. end
  409. entry = track_file(path)
  410. entry.in_source = true
  411. end
  412. end
  413. def track_file(path)
  414. if @entries.has_key?(path)
  415. return @entries[path]
  416. end
  417. entry = DiffEntry.new(path)
  418. @entries[path] = entry
  419. return entry
  420. end
  421. end
  422. # Finds files on the filesystem while ignoring files that have been declared to
  423. # be ignored.
  424. class DirectoryLister
  425. def initialize(root_dir)
  426. @root_dir = root_dir
  427. @ignore_basenames = ['.', '..']
  428. @ignore_pathnames = []
  429. end
  430. def add_patterns(patterns)
  431. patterns.each do |pattern|
  432. if File.basename(pattern) != pattern
  433. @ignore_pathnames.push(File.join(@root_dir, pattern))
  434. else
  435. @ignore_basenames.push(pattern)
  436. end
  437. end
  438. end
  439. # Finds filesystem entries that are immediate children of the given Pathname,
  440. # ignoring files that match the global ignore_files patterns.
  441. def entries(path)
  442. result = []
  443. path.entries.each do |entry|
  444. next if ignore_basename?(entry)
  445. file = path.join(entry)
  446. next if ignore_pathname?(file)
  447. result.push(file)
  448. end
  449. return result
  450. end
  451. private
  452. def ignore_basename?(basename)
  453. @ignore_basenames.each do |ignore|
  454. if basename.fnmatch(ignore)
  455. return true
  456. end
  457. end
  458. return false
  459. end
  460. def ignore_pathname?(file)
  461. @ignore_pathnames.each do |ignore|
  462. if file.fnmatch(ignore)
  463. return true
  464. end
  465. end
  466. return false
  467. end
  468. end
  469. if __FILE__ == $0
  470. main()
  471. end