sync_project.rb 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866
  1. #!/usr/bin/env 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 'cocoapods'
  21. require 'optparse'
  22. require 'pathname'
  23. # Note that xcodeproj 1.5.8 appears to be broken
  24. # https://github.com/CocoaPods/Xcodeproj/issues/572
  25. gem 'xcodeproj', '!= 1.5.8'
  26. require 'xcodeproj'
  27. ROOT_DIR = Pathname.new(__FILE__).dirname().join('..').expand_path()
  28. PODFILE_DIR = ROOT_DIR.join('Firestore', 'Example')
  29. def main()
  30. test_only = false
  31. OptionParser.new do |opts|
  32. opts.on('--test-only', 'Check diffs without writing') do |v|
  33. test_only = v
  34. end
  35. end.parse!
  36. # Make all filenames relative to the project root.
  37. Dir.chdir(ROOT_DIR.to_s)
  38. changes = sync_firestore(test_only)
  39. status = test_only && changes > 0 ? 2 : 0
  40. exit(status)
  41. end
  42. # Make it so that you can "add" hash literals together by merging their
  43. # contents.
  44. class Hash
  45. def +(other)
  46. return merge(other)
  47. end
  48. end
  49. def sync_firestore(test_only)
  50. project = Xcodeproj::Project.open('Firestore/Example/Firestore.xcodeproj')
  51. spec = Pod::Spec.from_file('FirebaseFirestoreInternal.podspec')
  52. swift_spec = Pod::Spec.from_file('FirebaseFirestore.podspec')
  53. # Enable warnings after opening the project to avoid the warnings in
  54. # xcodeproj itself
  55. $VERBOSE = true
  56. s = Syncer.new(project, ROOT_DIR)
  57. # Files on the filesystem that should be ignored.
  58. s.ignore_files = [
  59. 'CMakeLists.txt',
  60. 'README.md',
  61. 'InfoPlist.strings',
  62. '*.orig',
  63. '*.plist',
  64. '.*',
  65. ]
  66. # Folder groups in the Xcode project that contain tests.
  67. s.groups = [
  68. 'Tests',
  69. 'CoreTests',
  70. 'CoreTestsProtos',
  71. 'SwiftTests',
  72. ]
  73. # Copy key settings from the podspec
  74. podspec_settings = [
  75. 'CLANG_CXX_LANGUAGE_STANDARD',
  76. 'GCC_C_LANGUAGE_STANDARD',
  77. ]
  78. xcconfig_spec = spec.attributes_hash['pod_target_xcconfig'].dup
  79. xcconfig_spec.select! { |k, v| podspec_settings.include?(k) }
  80. # Settings for all Objective-C/C++ targets
  81. xcconfig_objc = xcconfig_spec + {
  82. 'INFOPLIST_FILE' => '"${SRCROOT}/Tests/Tests-Info.plist"',
  83. # Duplicate the header search paths from the main podspec because they're
  84. # phrased in terms of PODS_TARGET_SRCROOT, which isn't defined for other
  85. # targets.
  86. 'HEADER_SEARCH_PATHS' => [
  87. # Include fully qualified from the root of the repo
  88. '"${PODS_ROOT}/../../.."',
  89. # Make public headers available as "FIRQuery.h"
  90. '"${PODS_ROOT}/../../../Firestore/Source/Public/FirebaseFirestore"',
  91. # Make public headers available as "FirebaseFirestoreCpp.h"
  92. '"${PODS_ROOT}/../../../Firestore/core/src/api"',
  93. # Generated protobuf and nanopb output expects to search relative to the
  94. # output path.
  95. '"${PODS_ROOT}/../../../Firestore/Protos/cpp"',
  96. '"${PODS_ROOT}/../../../Firestore/Protos/nanopb"',
  97. # Other dependencies that assume #includes are relative to their roots.
  98. '"${PODS_ROOT}/../../../Firestore/third_party/abseil-cpp"',
  99. '"${PODS_ROOT}/GoogleBenchmark/include"',
  100. '"${PODS_ROOT}/GoogleTest/googlemock/include"',
  101. '"${PODS_ROOT}/GoogleTest/googletest/include"',
  102. '"${PODS_ROOT}/leveldb-library/include"',
  103. ],
  104. 'SYSTEM_HEADER_SEARCH_PATHS' => [
  105. # Nanopb wants to #include <pb.h>
  106. '"${PODS_ROOT}/nanopb"',
  107. # Protobuf wants to #include <google/protobuf/stubs/common.h>
  108. '"${PODS_ROOT}/ProtobufCpp/src"',
  109. ],
  110. 'OTHER_CFLAGS' => [
  111. # Protobuf C++ generates dead code.
  112. '-Wno-unreachable-code',
  113. # Our public build can't include -Werror, but for development it's quite
  114. # helpful.
  115. '-Werror'
  116. ]
  117. }
  118. xcconfig_swift = {
  119. 'SWIFT_OBJC_BRIDGING_HEADER' =>
  120. '${PODS_ROOT}/../../../Firestore/Swift/Tests/BridgingHeader.h',
  121. 'SWIFT_VERSION' => pick_swift_version(swift_spec),
  122. }
  123. ['iOS', 'macOS', 'tvOS'].each do |platform|
  124. s.target "Firestore_Example_#{platform}" do |t|
  125. t.xcconfig = xcconfig_objc + xcconfig_swift + {
  126. # Passing -all_load is required to get all our C++ code into the test
  127. # host.
  128. #
  129. # Normally when running tests, the test target contains only the tests
  130. # proper, and links against the test host for the code under test. The
  131. # test host doesn't do anything though, so the linker strips C++-only
  132. # object code away.
  133. #
  134. # This is particular to C++ because by default CocoaPods configures the
  135. # test host to link with the -ObjC flag. This causes the linker to pull
  136. # in all Objective-C object code. -all_load fixes this by forcing the
  137. # linker to pull in everything.
  138. 'OTHER_LDFLAGS' => '-all_load',
  139. }
  140. end
  141. s.target "Firestore_Tests_#{platform}" do |t|
  142. t.source_files = [
  143. 'Firestore/Example/Tests/**',
  144. 'Firestore/core/test/**',
  145. 'Firestore/Protos/cpp/**',
  146. ]
  147. t.exclude_files = [
  148. # needs to be in project but not in target
  149. 'Firestore/Example/Tests/Tests-Info.plist',
  150. # These files are integration tests, handled below
  151. 'Firestore/Example/Tests/Integration/**',
  152. ]
  153. t.xcconfig = xcconfig_objc + xcconfig_swift
  154. end
  155. end
  156. ['iOS', 'macOS', 'tvOS'].each do |platform|
  157. s.target "Firestore_IntegrationTests_#{platform}" do |t|
  158. t.source_files = [
  159. 'Firestore/Example/Tests/**',
  160. 'Firestore/Protos/cpp/**',
  161. 'Firestore/Swift/Tests/**',
  162. 'Firestore/core/test/**',
  163. ]
  164. t.exclude_files = [
  165. # needs to be in project but not in target
  166. 'Firestore/Example/Tests/Tests-Info.plist',
  167. ]
  168. t.xcconfig = xcconfig_objc + xcconfig_swift
  169. end
  170. s.target 'Firestore_Benchmarks_iOS' do |t|
  171. t.xcconfig = xcconfig_objc + {
  172. 'INFOPLIST_FILE' => '${SRCROOT}/Benchmarks/Info.plist',
  173. }
  174. end
  175. s.target 'Firestore_FuzzTests_iOS' do |t|
  176. t.xcconfig = xcconfig_objc + {
  177. 'INFOPLIST_FILE' =>
  178. '${SRCROOT}/FuzzTests/Firestore_FuzzTests_iOS-Info.plist',
  179. 'OTHER_CFLAGS' => [
  180. '-fsanitize=fuzzer',
  181. ]
  182. }
  183. end
  184. end
  185. changes = s.sync(test_only)
  186. if not test_only
  187. sort_project(project)
  188. if project.dirty?
  189. project.save()
  190. end
  191. end
  192. return changes
  193. end
  194. # Picks a swift version to use from a podspec's swift_versions
  195. def pick_swift_version(spec)
  196. versions = spec.attributes_hash['swift_versions']
  197. if versions.is_a?(Array)
  198. return versions[-1]
  199. end
  200. return versions
  201. end
  202. # A list of filesystem patterns
  203. class PatternList
  204. def initialize()
  205. @patterns = []
  206. end
  207. attr_accessor :patterns
  208. # Evaluates the rel_path against the given list of fnmatch patterns.
  209. def matches?(rel_path)
  210. @patterns.each do |pattern|
  211. if rel_path.fnmatch?(pattern)
  212. return true
  213. end
  214. end
  215. return false
  216. end
  217. end
  218. # The definition of a test target including the target name, its source_files
  219. # and exclude_files. A file is considered part of a target if it matches a
  220. # pattern in source_files but does not match a pattern in exclude_files.
  221. class TargetDef
  222. def initialize(name)
  223. @name = name
  224. @sync_sources = false
  225. @source_files = PatternList.new()
  226. @exclude_files = PatternList.new()
  227. @xcconfig = {}
  228. end
  229. attr_reader :name, :sync_sources, :source_files, :exclude_files
  230. attr_accessor :xcconfig
  231. def source_files=(value)
  232. @sync_sources = true
  233. @source_files.patterns.replace(value)
  234. end
  235. def exclude_files=(value)
  236. @exclude_files.patterns.replace(value)
  237. end
  238. # Returns true if the given rel_path matches this target's source_files
  239. # but not its exclude_files.
  240. #
  241. # Args:
  242. # - rel_path: a Pathname instance with a path relative to the project root.
  243. def matches?(rel_path)
  244. return @source_files.matches?(rel_path) && !@exclude_files.matches?(rel_path)
  245. end
  246. def diff(project_files, target)
  247. diff = Diff.new
  248. project_files.each do |file_ref|
  249. if matches?(relative_path(file_ref))
  250. entry = diff.track(file_ref.real_path)
  251. entry.in_source = true
  252. entry.ref = file_ref
  253. end
  254. end
  255. each_target_file(target) do |file_ref|
  256. entry = diff.track(file_ref.real_path)
  257. entry.in_target = true
  258. entry.ref = file_ref
  259. end
  260. return diff
  261. end
  262. # We're only managing synchronization of files in these phases.
  263. INTERESTING_PHASES = [
  264. Xcodeproj::Project::Object::PBXHeadersBuildPhase,
  265. Xcodeproj::Project::Object::PBXSourcesBuildPhase,
  266. Xcodeproj::Project::Object::PBXResourcesBuildPhase,
  267. ]
  268. # Finds all the files referred to by any phase in a target
  269. def each_target_file(target)
  270. target.build_phases.each do |phase|
  271. next if not INTERESTING_PHASES.include?(phase.class)
  272. phase.files.each do |build_file|
  273. yield build_file.file_ref
  274. end
  275. end
  276. end
  277. end
  278. class Syncer
  279. HEADERS = %w{.h}
  280. SOURCES = %w{.c .cc .m .mm .swift}
  281. def initialize(project, root_dir)
  282. @project = project
  283. @finder = DirectoryLister.new(root_dir)
  284. @groups = []
  285. @targets = []
  286. @seen_groups = {}
  287. end
  288. # Considers the given fnmatch glob patterns to be ignored by the syncer.
  289. # Patterns are matched both against the basename and project-relative
  290. # qualified pathname.
  291. def ignore_files=(patterns)
  292. @finder.add_patterns(patterns)
  293. end
  294. # Names the groups within the project that serve as roots for tests within
  295. # the project.
  296. def groups=(groups)
  297. @groups = []
  298. groups.each do |group|
  299. project_group = @project[group]
  300. if project_group.nil?
  301. raise "Project does not contain group #{group}"
  302. end
  303. @groups.push(@project[group])
  304. end
  305. end
  306. # Starts a new target block. Creates a new TargetDef and yields it.
  307. def target(name, &block)
  308. t = TargetDef.new(name)
  309. @targets.push(t)
  310. block.call(t)
  311. end
  312. # Finds the target definition with the given name.
  313. def find_target(name)
  314. @targets.each do |target|
  315. if target.name == name
  316. return target
  317. end
  318. end
  319. return nil
  320. end
  321. # Synchronizes the filesystem with the project.
  322. #
  323. # Generally there are three separate ways a file is referenced within a project:
  324. #
  325. # 1. The file must be in the global list of files, assigning it a UUID.
  326. # 2. The file must be added to folder groups, describing where it is in the
  327. # folder view of the Project Navigator.
  328. # 3. The file must be added to a target phase describing how it's built.
  329. #
  330. # The Xcodeproj library handles (1) for us automatically if we do (2).
  331. #
  332. # Returns the number of changes made during synchronization.
  333. def sync(test_only = false)
  334. # Figure the diff between the filesystem and the group structure
  335. group_differ = GroupDiffer.new(@finder)
  336. group_diff = group_differ.diff(@groups)
  337. changes = group_diff.changes
  338. to_remove = group_diff.to_remove
  339. # Add all files first, to ensure they exist for later steps
  340. add_to_project(group_diff.to_add)
  341. project_files = find_project_files_after_removal(@project.files, to_remove)
  342. @project.native_targets.each do |target|
  343. target_def = find_target(target.name)
  344. next if target_def.nil?
  345. if target_def.sync_sources
  346. target_diff = target_def.diff(project_files, target)
  347. target_diff.sorted_entries.each do |entry|
  348. changes += sync_target_entry(target, entry)
  349. end
  350. end
  351. if not test_only
  352. # Don't sync xcconfig changes in test-only mode.
  353. sync_xcconfig(target_def, target)
  354. end
  355. end
  356. remove_from_project(to_remove)
  357. return changes
  358. end
  359. private
  360. def find_project_files_after_removal(files, to_remove)
  361. remove_paths = Set.new()
  362. to_remove.each do |entry|
  363. remove_paths.add(entry.path)
  364. end
  365. result = []
  366. files.each do |file_ref|
  367. next if file_ref.source_tree != '<group>'
  368. next if remove_paths.include?(file_ref.real_path)
  369. path = file_ref.real_path
  370. next if @finder.ignore_basename?(path.basename)
  371. next if @finder.ignore_pathname?(path)
  372. result.push(file_ref)
  373. end
  374. return result
  375. end
  376. # Adds the given file to the project, in a path starting from the test root
  377. # that fully prefixes the file.
  378. def add_to_project(to_add)
  379. to_add.each do |entry|
  380. path = entry.path
  381. root_group = find_group_containing(path)
  382. # Find or create the group to contain the path.
  383. dir_rel_path = path.relative_path_from(root_group.real_path).dirname
  384. group = root_group.find_subpath(dir_rel_path.to_s, true)
  385. file_ref = group.new_file(path.to_s)
  386. ext = path.extname
  387. entry.ref = file_ref
  388. end
  389. end
  390. # Finds a group whose path prefixes the given entry. Starting from the
  391. # project root may not work since not all directories exist within the
  392. # example app.
  393. def find_group_containing(path)
  394. @groups.each do |group|
  395. rel = path.relative_path_from(group.real_path)
  396. next if rel.to_s.start_with?('..')
  397. return group
  398. end
  399. raise "Could not find an existing group that's a parent of #{entry.path}"
  400. end
  401. # Removes the given file references from the project after the file is found
  402. # to not exist on the filesystem but references to it still exist in the
  403. # project.
  404. def remove_from_project(to_remove)
  405. to_remove.each do |entry|
  406. file_ref = entry.ref
  407. file_ref.remove_from_project
  408. end
  409. end
  410. # Syncs a single build file for a given phase. Returns the number of changes
  411. # made.
  412. def sync_target_entry(target, entry)
  413. return 0 if entry.unchanged?
  414. phase = find_phase(target, entry.path)
  415. return 0 if phase.nil?
  416. mark_change_in_group(target.display_name)
  417. if entry.to_add?
  418. printf(" %s - added\n", basename(entry.ref))
  419. phase.add_file_reference(entry.ref)
  420. else
  421. printf(" %s - removed\n", basename(entry.ref))
  422. phase.remove_file_reference(entry.ref)
  423. end
  424. return 1
  425. end
  426. # Finds the phase to which the given pathname belongs based on its file
  427. # extension.
  428. #
  429. # Returns nil if the path does not belong in any phase.
  430. def find_phase(target, path)
  431. path = normalize_to_pathname(path)
  432. ext = path.extname
  433. if SOURCES.include?(ext)
  434. return target.source_build_phase
  435. elsif HEADERS.include?(ext)
  436. # TODO(wilhuff): sync headers
  437. #return target.headers_build_phase
  438. return nil
  439. else
  440. return target.resources_build_phase
  441. end
  442. end
  443. # Syncs build settings to the .xcconfig file for the build configuration,
  444. # avoiding any changes to the Xcode project file.
  445. def sync_xcconfig(target_def, target)
  446. dirty = false
  447. target.build_configurations.each do |config|
  448. requested = flatten(target_def.xcconfig)
  449. if config.base_configuration_reference.nil?
  450. # Running pod install with PLATFORM set to something other than "all"
  451. # ends up removing baseConfigurationReference entries from the project
  452. # file. Skip these entries when re-running.
  453. puts "Skipping #{target.name} (#{config.name})"
  454. next
  455. end
  456. path = PODFILE_DIR.join(config.base_configuration_reference.real_path)
  457. if !File.file?(path)
  458. puts "Skipping #{target.name} (#{config.name}); missing xcconfig"
  459. next
  460. end
  461. contents = Xcodeproj::Config.new(path)
  462. contents.merge!(requested)
  463. contents.save_as(path)
  464. end
  465. end
  466. # Converts a hash of lists to a flat hash of strings.
  467. def flatten(xcconfig)
  468. result = {}
  469. xcconfig.each do |key, value|
  470. if value.is_a?(Array)
  471. value = value.join(' ')
  472. end
  473. result[key] = value
  474. end
  475. return result
  476. end
  477. end
  478. def normalize_to_pathname(file_ref)
  479. if !file_ref.is_a? Pathname
  480. if file_ref.is_a? String
  481. file_ref = Pathname.new(file_ref)
  482. else
  483. file_ref = file_ref.real_path
  484. end
  485. end
  486. return file_ref
  487. end
  488. def basename(file_ref)
  489. return normalize_to_pathname(file_ref).basename
  490. end
  491. def relative_path(file_ref)
  492. path = normalize_to_pathname(file_ref)
  493. return path.relative_path_from(ROOT_DIR)
  494. end
  495. def mark_change_in_group(group)
  496. path = group.to_s
  497. if !@seen_groups.has_key?(path)
  498. puts "#{path} ..."
  499. @seen_groups[path] = true
  500. end
  501. end
  502. def sort_project(project)
  503. project.groups.each do |group|
  504. sort_group(group)
  505. end
  506. project.targets.each do |target|
  507. target.build_phases.each do |phase|
  508. phase.files.sort! { |a, b|
  509. a.file_ref.real_path.basename <=> b.file_ref.real_path.basename
  510. }
  511. end
  512. end
  513. end
  514. def sort_group(group)
  515. group.groups.each do |child|
  516. sort_group(child)
  517. end
  518. group.children.sort! do |a, b|
  519. # Sort groups first
  520. if a.isa == 'PBXGroup' && b.isa != 'PBXGroup'
  521. -1
  522. elsif a.isa != 'PBXGroup' && b.isa == 'PBXGroup'
  523. 1
  524. elsif a.display_name && b.display_name
  525. File.basename(a.display_name) <=> File.basename(b.display_name)
  526. else
  527. 0
  528. end
  529. end
  530. end
  531. # Tracks how a file is referenced: in the project file, on the filesystem,
  532. # neither, or both.
  533. class DiffEntry
  534. def initialize(path)
  535. @path = path
  536. @in_source = false
  537. @in_target = false
  538. @ref = nil
  539. end
  540. attr_reader :path
  541. attr_accessor :in_source, :in_target, :ref
  542. def unchanged?()
  543. return @in_source && @in_target
  544. end
  545. def to_add?()
  546. return @in_source && !@in_target
  547. end
  548. def to_remove?()
  549. return !@in_source && @in_target
  550. end
  551. end
  552. # A set of differences between some source and a target.
  553. class Diff
  554. def initialize()
  555. @entries = {}
  556. end
  557. attr_accessor :entries
  558. def track(path)
  559. if @entries.has_key?(path)
  560. return @entries[path]
  561. end
  562. entry = DiffEntry.new(path)
  563. @entries[path] = entry
  564. return entry
  565. end
  566. # Returns a list of entries that are to be added to the target
  567. def to_add()
  568. return @entries.values.select { |entry| entry.to_add? }
  569. end
  570. # Returns a list of entries that are to be removed to the target
  571. def to_remove()
  572. return @entries.values.select { |entry| entry.to_remove? }
  573. end
  574. # Returns a list of entries in sorted order.
  575. def sorted_entries()
  576. return @entries.values.sort { |a, b| a.path.basename <=> b.path.basename }
  577. end
  578. def changes()
  579. return @entries.values.count { |entry| entry.to_add? || entry.to_remove? }
  580. end
  581. end
  582. # Diffs folder groups against the filesystem directories referenced by those
  583. # folder groups.
  584. #
  585. # Folder groups in the project may each refer to an arbitrary path, so
  586. # traversing from a parent group to a subgroup may jump to a radically
  587. # different filesystem location or alias a previously processed directory.
  588. #
  589. # This class performs a diff by essentially tracking only whether or not a
  590. # given absolute path has been seen in either the filesystem or the group
  591. # structure, without paying attention to where in the group structure the file
  592. # reference actually occurs.
  593. #
  594. # This helps ensure that the default arbitrary splits in group structure are
  595. # preserved. For example, "Supporting Files" is an alias for the same directory
  596. # as the parent group, and Apple's default project setup hides some files in
  597. # "Supporting Files". The approach this diff takes preserves this arrangement
  598. # without understanding specifically which files should be hidden and which
  599. # should exist in the parent.
  600. #
  601. # However, this approach has limitations: removing a file from "Supporting
  602. # Files" will be handled, but re-adding the file is likely to add it to the
  603. # group that mirrors the filesystem hierarchy rather than back into its
  604. # original position. So far this approach has been acceptable because there's
  605. # nothing of value in these aliasing folders. Should this change we'll have to
  606. # revisit.
  607. class GroupDiffer
  608. def initialize(dir_lister)
  609. @dir_lister = dir_lister
  610. @dirs = {}
  611. @diff = Diff.new()
  612. end
  613. # Finds all files on the filesystem contained within the paths of the given
  614. # groups and computes a list of DiffEntries describing the state of the
  615. # files.
  616. #
  617. # Args:
  618. # - groups: A list of PBXGroup objects representing folder groups within the
  619. # project that contain files of interest.
  620. #
  621. # Returns:
  622. # A hash of Pathname to DiffEntry objects, one for each file found. If the
  623. # file exists on the filesystem, :in_source will be true. If the file exists
  624. # in the project :in_target will be true and :ref will be set to the
  625. # PBXFileReference naming the file.
  626. def diff(groups)
  627. groups.each do |group|
  628. diff_project_files(group)
  629. end
  630. return @diff
  631. end
  632. private
  633. # Recursively traverses all the folder groups in the Xcode project and finds
  634. # files both on the filesystem and the group file listing.
  635. def diff_project_files(group)
  636. find_fs_files(group.real_path)
  637. group.groups.each do |child|
  638. diff_project_files(child)
  639. end
  640. group.files.each do |file_ref|
  641. path = file_ref.real_path
  642. entry = @diff.track(path)
  643. entry.in_target = true
  644. entry.ref = file_ref
  645. if path.file?
  646. entry.in_source = true
  647. end
  648. end
  649. end
  650. def find_fs_files(parent_path)
  651. # Avoid re-traversing the filesystem
  652. if @dirs.has_key?(parent_path)
  653. return
  654. end
  655. @dirs[parent_path] = true
  656. @dir_lister.entries(parent_path).each do |path|
  657. if path.directory?
  658. find_fs_files(path)
  659. next
  660. end
  661. entry = @diff.track(path)
  662. entry.in_source = true
  663. end
  664. end
  665. end
  666. # Finds files on the filesystem while ignoring files that have been declared to
  667. # be ignored.
  668. class DirectoryLister
  669. def initialize(root_dir)
  670. @root_dir = root_dir
  671. @ignore_basenames = ['.', '..']
  672. @ignore_pathnames = []
  673. end
  674. def add_patterns(patterns)
  675. patterns.each do |pattern|
  676. if File.basename(pattern) != pattern
  677. @ignore_pathnames.push(File.join(@root_dir, pattern))
  678. else
  679. @ignore_basenames.push(pattern)
  680. end
  681. end
  682. end
  683. # Finds filesystem entries that are immediate children of the given Pathname,
  684. # ignoring files that match the global ignore_files patterns.
  685. def entries(path)
  686. result = []
  687. return result if not path.exist?
  688. path.entries.each do |entry|
  689. next if ignore_basename?(entry)
  690. file = path.join(entry)
  691. next if ignore_pathname?(file)
  692. result.push(file)
  693. end
  694. return result
  695. end
  696. def ignore_basename?(basename)
  697. @ignore_basenames.each do |ignore|
  698. if basename.fnmatch(ignore)
  699. return true
  700. end
  701. end
  702. return false
  703. end
  704. def ignore_pathname?(file)
  705. @ignore_pathnames.each do |ignore|
  706. if file.fnmatch(ignore)
  707. return true
  708. end
  709. end
  710. return false
  711. end
  712. end
  713. if __FILE__ == $0
  714. main()
  715. end