gerrit-clone 2.2.4__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/.pre-commit-config.yaml +4 -4
  2. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/PKG-INFO +37 -18
  3. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/README.md +36 -17
  4. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_clone_run.py +31 -98
  5. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_clone_setup.py +11 -3
  6. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_mirror_run.py +27 -4
  7. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_refresh_run.py +51 -121
  8. gerrit_clone-2.3.0/src/gerrit_clone/content_intent.py +230 -0
  9. gerrit_clone-2.3.0/src/gerrit_clone/content_intent_resolve.py +154 -0
  10. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_policy.py +38 -71
  11. gerrit_clone-2.3.0/src/gerrit_clone/content_spec.py +130 -0
  12. gerrit_clone-2.3.0/src/gerrit_clone/content_stage.py +99 -0
  13. gerrit_clone-2.3.0/src/gerrit_clone/mirror_cleanup.py +166 -0
  14. gerrit_clone-2.3.0/src/gerrit_clone/mirror_content_filters.py +150 -0
  15. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_manager.py +27 -33
  16. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/models.py +1 -1
  17. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_filtered.py +45 -7
  18. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_manager.py +2 -2
  19. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_parallel.py +1 -1
  20. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_worker.py +18 -4
  21. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/conftest.py +164 -1
  22. gerrit_clone-2.3.0/tests/test_content_intent.py +435 -0
  23. gerrit_clone-2.3.0/tests/test_content_intent_commands.py +458 -0
  24. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_content_legacy.py +149 -4
  25. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_content_policy.py +2 -2
  26. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_mirror_manager.py +21 -7
  27. gerrit_clone-2.3.0/tests/test_network_guard.py +252 -0
  28. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_refresh_bare_mirrors.py +47 -11
  29. gerrit_clone-2.2.4/src/gerrit_clone/mirror_cleanup.py +0 -68
  30. gerrit_clone-2.2.4/src/gerrit_clone/mirror_content_filters.py +0 -218
  31. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/.gitignore +0 -0
  32. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/IMPLEMENTATION.md +0 -0
  33. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/LICENSE +0 -0
  34. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/action.yaml +0 -0
  35. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/pyproject.toml +0 -0
  36. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/__init__.py +0 -0
  37. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli.py +0 -0
  38. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_app.py +0 -0
  39. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_clone.py +0 -0
  40. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_clone_models.py +0 -0
  41. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_config.py +0 -0
  42. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_hooks.py +0 -0
  43. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_mirror.py +0 -0
  44. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_mirror_models.py +0 -0
  45. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_mirror_setup.py +0 -0
  46. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_options_clone.py +0 -0
  47. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_options_mirror.py +0 -0
  48. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_options_refresh.py +0 -0
  49. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_options_reset.py +0 -0
  50. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_refresh.py +0 -0
  51. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_refresh_report.py +0 -0
  52. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_reset.py +0 -0
  53. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_reset_run.py +0 -0
  54. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/cli_session.py +0 -0
  55. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_batching.py +0 -0
  56. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_cleanup.py +0 -0
  57. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_collection.py +0 -0
  58. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_conflicts.py +0 -0
  59. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_diagnostics.py +0 -0
  60. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_git_env.py +0 -0
  61. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_locking.py +0 -0
  62. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_manager.py +0 -0
  63. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_nesting.py +0 -0
  64. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_orchestrator.py +0 -0
  65. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_ordering.py +0 -0
  66. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_refresh.py +0 -0
  67. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_reporting.py +0 -0
  68. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_reservations.py +0 -0
  69. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_results.py +0 -0
  70. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_retry_policy.py +0 -0
  71. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_stages.py +0 -0
  72. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_timeout.py +0 -0
  73. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/clone_utils.py +0 -0
  74. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/concurrent_utils.py +0 -0
  75. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/config.py +0 -0
  76. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/config_env.py +0 -0
  77. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/config_errors.py +0 -0
  78. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_filter.py +0 -0
  79. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_git.py +0 -0
  80. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_legacy.py +0 -0
  81. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_origin.py +0 -0
  82. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_patterns.py +0 -0
  83. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_redaction.py +0 -0
  84. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_removal.py +0 -0
  85. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_scan.py +0 -0
  86. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_scan_process.py +0 -0
  87. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/content_worktree.py +0 -0
  88. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/discovery.py +0 -0
  89. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/error_codes.py +0 -0
  90. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/error_collection.py +0 -0
  91. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/file_logging.py +0 -0
  92. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/gerrit_api.py +0 -0
  93. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/git_comparison.py +0 -0
  94. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/git_credential_env.py +0 -0
  95. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/git_utils.py +0 -0
  96. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_api.py +0 -0
  97. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_async_pacing.py +0 -0
  98. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_async_repo.py +0 -0
  99. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_batch_support.py +0 -0
  100. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_clone_env.py +0 -0
  101. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_clone_results.py +0 -0
  102. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_clone_url.py +0 -0
  103. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_discovery.py +0 -0
  104. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_gh_cli.py +0 -0
  105. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_graphql.py +0 -0
  106. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_models.py +0 -0
  107. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_repo_ops.py +0 -0
  108. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_token_hygiene.py +0 -0
  109. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_transport.py +0 -0
  110. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_url_safety.py +0 -0
  111. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/github_worker.py +0 -0
  112. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/logging.py +0 -0
  113. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_branch_repair.py +0 -0
  114. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_default_branch.py +0 -0
  115. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_filtering.py +0 -0
  116. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_git_refs.py +0 -0
  117. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_models.py +0 -0
  118. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_planning.py +0 -0
  119. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_push.py +0 -0
  120. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/mirror_result_builder.py +0 -0
  121. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/model_clone_results.py +0 -0
  122. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/model_config_validation.py +0 -0
  123. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/model_enums.py +0 -0
  124. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/model_project.py +0 -0
  125. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/model_project_filters.py +0 -0
  126. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/model_refresh_results.py +0 -0
  127. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/netrc.py +0 -0
  128. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/netrc_discovery.py +0 -0
  129. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/netrc_models.py +0 -0
  130. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/netrc_parser.py +0 -0
  131. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/output_utils.py +0 -0
  132. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/path_errors.py +0 -0
  133. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/path_naming.py +0 -0
  134. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/pathing.py +0 -0
  135. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/process_groups.py +0 -0
  136. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/progress.py +0 -0
  137. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/progress_display.py +0 -0
  138. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/progress_live.py +0 -0
  139. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/progress_modes.py +0 -0
  140. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/progress_results.py +0 -0
  141. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/progress_summary.py +0 -0
  142. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/py.typed +0 -0
  143. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/rate_limit.py +0 -0
  144. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/rate_limit_budget.py +0 -0
  145. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/rate_limit_helpers.py +0 -0
  146. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/rate_limit_progress.py +0 -0
  147. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_branch_repair.py +0 -0
  148. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_discovery.py +0 -0
  149. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_execution.py +0 -0
  150. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_force.py +0 -0
  151. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_git_env.py +0 -0
  152. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_output.py +0 -0
  153. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/refresh_repo_state.py +0 -0
  154. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/reset_confirmation.py +0 -0
  155. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/reset_deletion.py +0 -0
  156. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/reset_display.py +0 -0
  157. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/reset_manager.py +0 -0
  158. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/reset_models.py +0 -0
  159. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/retry.py +0 -0
  160. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/rich_optional.py +0 -0
  161. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/rich_status.py +0 -0
  162. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/ssh_discovery.py +0 -0
  163. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/ssh_identity.py +0 -0
  164. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/subprocess_streaming.py +0 -0
  165. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/subprocess_tracking.py +0 -0
  166. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/unified_discovery.py +0 -0
  167. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/worker.py +0 -0
  168. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/src/gerrit_clone/worker_preflight.py +0 -0
  169. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/__init__.py +0 -0
  170. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/integration/__init__.py +0 -0
  171. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/integration/test_discovery_integration.py +0 -0
  172. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/integration/test_github_clone.py +0 -0
  173. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_action_cli_alignment.py +0 -0
  174. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_cli_netrc_options.py +0 -0
  175. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_clone_git_env.py +0 -0
  176. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_clone_manager_github_refresh.py +0 -0
  177. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_clone_nesting.py +0 -0
  178. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_clone_orchestrator_timeout.py +0 -0
  179. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_clone_results.py +0 -0
  180. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_clone_utils.py +0 -0
  181. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_config.py +0 -0
  182. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_content_filter.py +0 -0
  183. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_content_origin.py +0 -0
  184. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_content_redaction.py +0 -0
  185. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_content_tracking.py +0 -0
  186. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_content_worktree_failures.py +0 -0
  187. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_file_logging_changes.py +0 -0
  188. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_gerrit_api.py +0 -0
  189. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_git_comparison.py +0 -0
  190. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_git_credential_env.py +0 -0
  191. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_git_utils.py +0 -0
  192. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_github_api.py +0 -0
  193. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_logging.py +0 -0
  194. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_models.py +0 -0
  195. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_netrc.py +0 -0
  196. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_output_utils.py +0 -0
  197. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_pathing.py +0 -0
  198. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_project_filtering.py +0 -0
  199. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_rate_limit.py +0 -0
  200. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_refresh_manager.py +0 -0
  201. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_refresh_remotes.py +0 -0
  202. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_refresh_tracking.py +0 -0
  203. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_refresh_worker.py +0 -0
  204. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_reset_manager.py +0 -0
  205. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_reset_models.py +0 -0
  206. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_retry.py +0 -0
  207. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_rich_optional.py +0 -0
  208. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_ssh_identity.py +0 -0
  209. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_subprocess_tracking.py +0 -0
  210. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/test_worker.py +0 -0
  211. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/unit/__init__.py +0 -0
  212. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/unit/test_discovery.py +0 -0
  213. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/unit/test_github_discovery.py +0 -0
  214. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/unit/test_github_worker.py +0 -0
  215. {gerrit_clone-2.2.4 → gerrit_clone-2.3.0}/tests/unit/test_unified_discovery.py +0 -0
@@ -74,14 +74,14 @@ repos:
74
74
  types: [yaml]
75
75
 
76
76
  - repo: https://github.com/astral-sh/ruff-pre-commit
77
- rev: a56c0b927e6465d37cae3e97d35d4d18ab2b96cd # frozen: v0.16.9
77
+ rev: f12be1ebaa5351c1fc76472de98db2c3446c8253 # frozen: v0.16.10
78
78
  hooks:
79
79
  - id: ruff
80
80
  args: [--fix, --exit-non-zero-on-fix]
81
81
  - id: ruff-format
82
82
 
83
83
  - repo: https://github.com/pre-commit/mirrors-mypy
84
- rev: 7ff8d35ae36a7d2b968f2f90b4c723e292e594ee # frozen: v2.3.1
84
+ rev: 2834ec6639549dd6796205c8f011dedcd587288b # frozen: v2.4.0
85
85
  hooks:
86
86
  - id: mypy
87
87
  additional_dependencies:
@@ -126,12 +126,12 @@ repos:
126
126
  - id: codespell
127
127
 
128
128
  - repo: https://github.com/DetachHead/basedpyright-prek-mirror
129
- rev: 706e6acebe9e88c2c28d1527d3085a14b11794c4 # frozen: 1.40.1
129
+ rev: a11caebebdbf797e1e96611123db279b334a7675 # frozen: 1.40.2
130
130
  hooks:
131
131
  - id: basedpyright
132
132
 
133
133
  - repo: https://github.com/lfreleng-actions/gha-workflow-linter
134
- rev: 0c582e431022f1c7004196fb4ea32744d1fe4843 # frozen: v1.7.0
134
+ rev: b23b60e5884e985320a030e6114032152e2373cd # frozen: v1.7.1
135
135
  hooks:
136
136
  - id: gha-workflow-linter
137
137
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: gerrit-clone
3
- Version: 2.2.4
3
+ Version: 2.3.0
4
4
  Summary: A multi-threaded CLI tool for bulk cloning repositories from Gerrit servers
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/gerrit-clone-action
6
6
  Project-URL: Documentation, https://github.com/lfreleng-actions/gerrit-clone-action#readme
@@ -237,24 +237,35 @@ nothing. If the
237
237
  command finds no repositories at all, it says so and exits with status 1
238
238
  rather than reporting success.
239
239
 
240
- A repository that content filtering (`--remove-files`, `--git-filter`,
241
- `--redact-secrets`) has rewritten gets refreshed only by a run whose filters
242
- include the ones that rewrote it, since fetching forces the original history
243
- back, filtered content included. Each repository records which filters
244
- rewrote it, keeping each `--git-filter` token as a SHA-256 digest only.
245
- Refresh copies such a mirror, fetches and filters the copy, and publishes
246
- its refs only when every step succeeds, so a failure leaves the mirror
247
- unchanged. A run without those filters skips the mirror and says which
248
- filters it lacks; the same applies to re-running `clone` and `mirror`. A
249
- filtered working copy (`--no-mirror`) is always skipped: re-clone it to
250
- update it. Every remote of a filtered repository refuses pushes.
251
- Releases up to v2.2.4 recorded no filters, so refresh skips any
252
- repository they filtered, whatever filters you pass, and asks you to
253
- re-clone it. It recognises one by the traces those releases left: the
240
+ Content filtering (`--remove-files`, `--git-filter`, `--redact-secrets`) is a
241
+ decision about a whole clone tree, and later runs keep to it. The first run
242
+ that filters writes the decision to `.gerrit-clone/filter-policy.json` at the
243
+ root of the tree. Every later `clone`, `refresh` or `mirror` in that tree
244
+ applies it, even without the options: to repositories cloned later, to files
245
+ that appear upstream later, and to a repository you delete and clone again.
246
+ Options add to the decision; leaving one out never removes a filter. The file
247
+ keeps each `--git-filter` token as a SHA-256 digest only, so a run must pass
248
+ those tokens again. If a run lacks a project's tokens, `refresh` skips that
249
+ project, `clone` exits with a filtering failure, and `mirror` aborts the
250
+ whole batch before it pushes anything; each gives the number of tokens the
251
+ project needs. A file the tool cannot read stops the run.
252
+
253
+ Each repository also records which filters rewrote it. Fetching would force
254
+ the original history back, filtered content included, so refresh copies each
255
+ mirror it filters, fetches and filters the copy, and publishes its refs only
256
+ when every step succeeds; a failure leaves the mirror unchanged. A filtered
257
+ working copy (`--no-mirror`) always gets skipped: re-clone it to update it.
258
+ Every remote of a filtered repository refuses pushes. A tree filtered before
259
+ the tree-level file existed gains one from these per-repository records on
260
+ its next run. Releases up to v2.2.4 recorded no filters at all, so refresh
261
+ skips any repository they filtered, whatever filters you pass, and asks you
262
+ to re-clone it, and `mirror --overwrite` leaves it out of the run rather
263
+ than cloning or pushing it: delete it yourself, then clone it again with the
264
+ filters it needs. The tool recognises one by the traces those releases left: the
254
265
  commit map a `git filter-repo` rewrite leaves in the git directory, or a
255
266
  branch tip committed by the file-removal fallback (`Remove filtered files
256
267
  for platform sync`).
257
- A `--dry-run` applies no content filters.
268
+ A `--dry-run` applies no content filters and records nothing.
258
269
 
259
270
  Refresh all repositories in the current directory:
260
271
 
@@ -915,8 +926,16 @@ export GITHUB_TOKEN=github_pat_your_token_here
915
926
 
916
927
  **With `--overwrite` flag**:
917
928
 
918
- - Removes and re-clones local repositories
919
- - Still skips existing GitHub repositories unless `--recreate` is also used
929
+ - Removes and re-clones local repositories, then pushes each one
930
+ (`git push --mirror`) to its GitHub repository, existing ones included;
931
+ the tree's recorded content filters still apply first
932
+ - Leaves out of the run any repository a release up to v2.2.4
933
+ content-filtered, and any directory holding one: those releases recorded
934
+ no filters to apply again, so the run neither re-clones nor pushes it,
935
+ `--recreate` leaves its GitHub repository alone, and the manifest lists
936
+ it as skipped
937
+ - Without `--recreate`, reuses existing GitHub repositories rather than
938
+ deleting them
920
939
 
921
940
  **With both `--recreate` and `--overwrite`**:
922
941
 
@@ -195,24 +195,35 @@ nothing. If the
195
195
  command finds no repositories at all, it says so and exits with status 1
196
196
  rather than reporting success.
197
197
 
198
- A repository that content filtering (`--remove-files`, `--git-filter`,
199
- `--redact-secrets`) has rewritten gets refreshed only by a run whose filters
200
- include the ones that rewrote it, since fetching forces the original history
201
- back, filtered content included. Each repository records which filters
202
- rewrote it, keeping each `--git-filter` token as a SHA-256 digest only.
203
- Refresh copies such a mirror, fetches and filters the copy, and publishes
204
- its refs only when every step succeeds, so a failure leaves the mirror
205
- unchanged. A run without those filters skips the mirror and says which
206
- filters it lacks; the same applies to re-running `clone` and `mirror`. A
207
- filtered working copy (`--no-mirror`) is always skipped: re-clone it to
208
- update it. Every remote of a filtered repository refuses pushes.
209
- Releases up to v2.2.4 recorded no filters, so refresh skips any
210
- repository they filtered, whatever filters you pass, and asks you to
211
- re-clone it. It recognises one by the traces those releases left: the
198
+ Content filtering (`--remove-files`, `--git-filter`, `--redact-secrets`) is a
199
+ decision about a whole clone tree, and later runs keep to it. The first run
200
+ that filters writes the decision to `.gerrit-clone/filter-policy.json` at the
201
+ root of the tree. Every later `clone`, `refresh` or `mirror` in that tree
202
+ applies it, even without the options: to repositories cloned later, to files
203
+ that appear upstream later, and to a repository you delete and clone again.
204
+ Options add to the decision; leaving one out never removes a filter. The file
205
+ keeps each `--git-filter` token as a SHA-256 digest only, so a run must pass
206
+ those tokens again. If a run lacks a project's tokens, `refresh` skips that
207
+ project, `clone` exits with a filtering failure, and `mirror` aborts the
208
+ whole batch before it pushes anything; each gives the number of tokens the
209
+ project needs. A file the tool cannot read stops the run.
210
+
211
+ Each repository also records which filters rewrote it. Fetching would force
212
+ the original history back, filtered content included, so refresh copies each
213
+ mirror it filters, fetches and filters the copy, and publishes its refs only
214
+ when every step succeeds; a failure leaves the mirror unchanged. A filtered
215
+ working copy (`--no-mirror`) always gets skipped: re-clone it to update it.
216
+ Every remote of a filtered repository refuses pushes. A tree filtered before
217
+ the tree-level file existed gains one from these per-repository records on
218
+ its next run. Releases up to v2.2.4 recorded no filters at all, so refresh
219
+ skips any repository they filtered, whatever filters you pass, and asks you
220
+ to re-clone it, and `mirror --overwrite` leaves it out of the run rather
221
+ than cloning or pushing it: delete it yourself, then clone it again with the
222
+ filters it needs. The tool recognises one by the traces those releases left: the
212
223
  commit map a `git filter-repo` rewrite leaves in the git directory, or a
213
224
  branch tip committed by the file-removal fallback (`Remove filtered files
214
225
  for platform sync`).
215
- A `--dry-run` applies no content filters.
226
+ A `--dry-run` applies no content filters and records nothing.
216
227
 
217
228
  Refresh all repositories in the current directory:
218
229
 
@@ -873,8 +884,16 @@ export GITHUB_TOKEN=github_pat_your_token_here
873
884
 
874
885
  **With `--overwrite` flag**:
875
886
 
876
- - Removes and re-clones local repositories
877
- - Still skips existing GitHub repositories unless `--recreate` is also used
887
+ - Removes and re-clones local repositories, then pushes each one
888
+ (`git push --mirror`) to its GitHub repository, existing ones included;
889
+ the tree's recorded content filters still apply first
890
+ - Leaves out of the run any repository a release up to v2.2.4
891
+ content-filtered, and any directory holding one: those releases recorded
892
+ no filters to apply again, so the run neither re-clones nor pushes it,
893
+ `--recreate` leaves its GitHub repository alone, and the manifest lists
894
+ it as skipped
895
+ - Without `--recreate`, reuses existing GitHub repositories rather than
896
+ deleting them
878
897
 
879
898
  **With both `--recreate` and `--overwrite`**:
880
899
 
@@ -21,12 +21,7 @@ from rich.text import Text
21
21
  from gerrit_clone import __version__, cli_hooks
22
22
  from gerrit_clone import cli_clone_setup as setup
23
23
  from gerrit_clone.cli_app import is_github_actions_context
24
- from gerrit_clone.content_filter import (
25
- apply_content_filters,
26
- is_shallow_repository,
27
- normalize_file_patterns,
28
- parse_git_filter_spec,
29
- )
24
+ from gerrit_clone.content_stage import filter_repository
30
25
  from gerrit_clone.error_codes import DiscoveryError, ExitCode
31
26
  from gerrit_clone.rich_status import (
32
27
  handle_crash_display,
@@ -37,7 +32,8 @@ from gerrit_clone.rich_status import (
37
32
  if TYPE_CHECKING:
38
33
  from gerrit_clone.cli_clone_models import CloneRequest
39
34
  from gerrit_clone.cli_session import CliSession
40
- from gerrit_clone.models import BatchResult, CloneResult, Config
35
+ from gerrit_clone.content_spec import ContentFilterSpec
36
+ from gerrit_clone.models import BatchResult, Config
41
37
 
42
38
 
43
39
  def run_clone(request: CloneRequest, session: CliSession) -> None:
@@ -70,7 +66,7 @@ def run_clone(request: CloneRequest, session: CliSession) -> None:
70
66
  setup.show_startup_banner(console, config)
71
67
 
72
68
  batch_result = _clone_repositories(config)
73
- _apply_content_filters(request, console, batch_result)
69
+ _apply_content_filters(request, console, batch_result, config.content_filters)
74
70
  _report_results(request, session, batch_result)
75
71
  exit_code = _determine_exit_code(session, batch_result)
76
72
 
@@ -105,116 +101,53 @@ def _clone_repositories(config: Config) -> BatchResult:
105
101
 
106
102
 
107
103
  def _apply_content_filters(
108
- request: CloneRequest, console: Console, batch_result: BatchResult
104
+ request: CloneRequest,
105
+ console: Console,
106
+ batch_result: BatchResult,
107
+ spec: ContentFilterSpec | None,
109
108
  ) -> None:
110
- """Apply content filters to successfully cloned repositories, if requested."""
111
- remove_file_patterns = (
112
- normalize_file_patterns([request.remove_files])
113
- if request.remove_files
114
- else None
115
- )
116
- git_filter_projects = (
117
- parse_git_filter_spec(request.git_filter) if request.git_filter else None
118
- )
119
- if not (remove_file_patterns or git_filter_projects or request.redact_secrets):
109
+ """Filter every successful clone, as each project's filters decide."""
110
+ if spec is None:
120
111
  return
121
112
 
122
113
  if not request.quiet:
123
114
  console.print("[cyan]🔧 Applying content filters...[/cyan]")
124
115
  filter_success = filter_fail = 0
125
116
  for cr in batch_result.results:
117
+ if cr.skipped and cr.path and spec.missing_tokens(cr.path):
118
+ # The refresh pass refused it for the tokens, before any
119
+ # fetch. Clone reports that as the filtering failure it is,
120
+ # whether or not upstream had moved on.
121
+ filter_fail += 1
122
+ if not request.quiet:
123
+ console.print(
124
+ f"[yellow]⚠️ Filter failed for {cr.project.name}: "
125
+ f"{cr.error_message}[/yellow]"
126
+ )
127
+ continue
126
128
  if not cr.success or not cr.path:
127
129
  continue
128
130
  if cr.content_filtered:
129
131
  # Re-filtered as part of its staged refresh already.
130
132
  filter_success += 1
131
133
  continue
132
- succeeded, failed = _filter_repository(
133
- request, console, cr, remove_file_patterns, git_filter_projects
134
+ reason = filter_repository(
135
+ spec, cr.path, spec.project_name(cr.path), request.clone_timeout
134
136
  )
135
- filter_success += succeeded
136
- filter_fail += failed
137
- if not request.quiet:
138
- console.print(
139
- f"[cyan]Content filtering: {filter_success} succeeded, {filter_fail} failed[/cyan]"
140
- )
141
- if filter_fail > 0:
142
- raise typer.Exit(ExitCode.GENERAL_ERROR)
143
-
144
-
145
- def _filter_repository(
146
- request: CloneRequest,
147
- console: Console,
148
- result: CloneResult,
149
- remove_file_patterns: list[str] | None,
150
- git_filter_projects: dict[str, list[str]] | None,
151
- ) -> tuple[int, int]:
152
- """Filter one cloned repository.
153
-
154
- Returns:
155
- Tuple of (successes, failures) to add to the run totals
156
- """
157
- # Fail closed on shallow repositories when history-
158
- # dependent filters are requested: --git-filter /
159
- # --redact-secrets rely on the full commit history, so
160
- # a shallow repo can hide older secrets (and a later
161
- # unshallow fetch could reintroduce blocked content),
162
- # giving a false sense of safety. Probe each repo
163
- # individually rather than only checking config.depth:
164
- # a repo that already existed locally (e.g. cloned
165
- # earlier with --depth) is shallow even when this run
166
- # sets no --depth. --remove-files targets file paths
167
- # present at the branch tips and does not depend on
168
- # full history being available (though it may still
169
- # rewrite history via git filter-repo when that tool
170
- # is present), so it is still applied; only the unsafe
171
- # history-scanning filters are dropped for the shallow
172
- # repo.
173
- repo_git_filter = git_filter_projects
174
- repo_redact = request.redact_secrets
175
- history_filters_skipped = False
176
- if (git_filter_projects or request.redact_secrets) and (
177
- is_shallow_repository(result.path)
178
- ):
137
+ if reason is None:
138
+ filter_success += 1
139
+ continue
140
+ filter_fail += 1
179
141
  if not request.quiet:
180
142
  console.print(
181
- "[red]❌ Refusing to run --git-filter / "
182
- "--redact-secrets on shallow repo "
183
- f"{result.project.name}: truncated history can hide "
184
- "older secrets. Re-clone without --depth.[/red]"
143
+ f"[yellow]⚠️ Filter failed for {cr.project.name}: {reason}[/yellow]"
185
144
  )
186
- # The requested redaction/rewrite did not run, so
187
- # this repo counts as a filtering failure even if
188
- # the safe --remove-files step below succeeds.
189
- history_filters_skipped = True
190
- repo_git_filter = None
191
- repo_redact = False
192
- if not remove_file_patterns:
193
- # Nothing history-independent left to do.
194
- return 0, 1
195
- success, error = apply_content_filters(
196
- result.path,
197
- result.project.name,
198
- remove_patterns=remove_file_patterns,
199
- git_filter_projects=repo_git_filter,
200
- redact_secrets=repo_redact,
201
- timeout=request.clone_timeout,
202
- )
203
- if history_filters_skipped:
204
- # Already counted as a failure above; don't also count
205
- # the safe --remove-files step as a success.
206
- if not success and not request.quiet:
207
- console.print(
208
- f"[yellow]⚠️ Filter failed for {result.project.name}: {error}[/yellow]"
209
- )
210
- return 0, 1
211
- if success:
212
- return 1, 0
213
145
  if not request.quiet:
214
146
  console.print(
215
- f"[yellow]⚠️ Filter failed for {result.project.name}: {error}[/yellow]"
147
+ f"[cyan]Content filtering: {filter_success} succeeded, {filter_fail} failed[/cyan]"
216
148
  )
217
- return 0, 1
149
+ if filter_fail > 0:
150
+ raise typer.Exit(ExitCode.GENERAL_ERROR)
218
151
 
219
152
 
220
153
  def _report_results(
@@ -21,7 +21,9 @@ from rich.text import Text
21
21
  from gerrit_clone import cli_hooks
22
22
  from gerrit_clone.cli_app import format_version_string
23
23
  from gerrit_clone.config import ConfigurationError, load_config
24
- from gerrit_clone.content_policy import ContentFilterSpec
24
+ from gerrit_clone.content_intent import IntentError
25
+ from gerrit_clone.content_intent_resolve import resolve_filters
26
+ from gerrit_clone.content_spec import ContentFilterSpec
25
27
  from gerrit_clone.error_codes import ExitCode
26
28
  from gerrit_clone.file_logging import (
27
29
  cli_args_to_dict,
@@ -318,13 +320,19 @@ def build_config(
318
320
  except ConfigurationError as e:
319
321
  _report_configuration_error(session, e)
320
322
  raise typer.Exit(ExitCode.CONFIGURATION_ERROR) from e
321
- config.content_filters = ContentFilterSpec.from_options(
323
+ options = ContentFilterSpec.from_options(
322
324
  request.remove_files, request.git_filter, request.redact_secrets, config.path
323
325
  )
326
+ try:
327
+ # Written down before anything is cloned or refreshed.
328
+ config.content_filters = resolve_filters(config.path, options, persist=True)
329
+ except IntentError as e:
330
+ _report_configuration_error(session, e)
331
+ raise typer.Exit(ExitCode.CONFIGURATION_ERROR) from e
324
332
  return config
325
333
 
326
334
 
327
- def _report_configuration_error(session: CliSession, error: ConfigurationError) -> None:
335
+ def _report_configuration_error(session: CliSession, error: Exception) -> None:
328
336
  """Log and display a configuration error before aborting."""
329
337
  if session.file_logger:
330
338
  session.file_logger.error("Configuration error: %s", str(error))
@@ -28,6 +28,7 @@ from gerrit_clone.cli_mirror_setup import (
28
28
  validate_request,
29
29
  )
30
30
  from gerrit_clone.content_filter import normalize_file_patterns, parse_git_filter_spec
31
+ from gerrit_clone.content_intent import IntentError
31
32
  from gerrit_clone.error_codes import ExitCode
32
33
  from gerrit_clone.mirror_manager import (
33
34
  MirrorBatchResult,
@@ -43,6 +44,7 @@ if TYPE_CHECKING:
43
44
 
44
45
  from gerrit_clone.cli_mirror_models import MirrorRequest
45
46
  from gerrit_clone.cli_session import CliSession
47
+ from gerrit_clone.mirror_models import MirrorResult
46
48
  from gerrit_clone.models import Config, Project
47
49
 
48
50
 
@@ -102,11 +104,17 @@ def run_mirror(request: MirrorRequest, session: CliSession) -> None:
102
104
  redact_secrets=request.redact_secrets,
103
105
  )
104
106
 
107
+ if not projects_to_mirror:
108
+ # Nothing to mirror, but the run's filters still decide for the
109
+ # projects a later run selects: the manager records them.
110
+ _mirror(mirror_manager, [], console)
111
+ raise typer.Exit(0)
112
+
105
113
  started_at = datetime.now(UTC)
106
114
  if not request.quiet:
107
115
  console.print("🚀 Starting mirror operation...")
108
116
 
109
- results = mirror_manager.mirror_projects(projects_to_mirror)
117
+ results = _mirror(mirror_manager, projects_to_mirror, console)
110
118
 
111
119
  completed_at = datetime.now(UTC)
112
120
 
@@ -130,6 +138,17 @@ def run_mirror(request: MirrorRequest, session: CliSession) -> None:
130
138
  _finish(request, session, batch_result)
131
139
 
132
140
 
141
+ def _mirror(
142
+ mirror_manager: MirrorManager, projects: list[Project], console: Console
143
+ ) -> list[MirrorResult]:
144
+ """Mirror *projects*; a tree whose filter intent is unreadable stops the run."""
145
+ try:
146
+ return mirror_manager.mirror_projects(projects)
147
+ except IntentError as exc:
148
+ console.print(f"[red]❌ {exc}[/red]")
149
+ raise typer.Exit(ExitCode.CONFIGURATION_ERROR) from exc
150
+
151
+
133
152
  def _select_projects(
134
153
  request: MirrorRequest,
135
154
  console: Console,
@@ -137,12 +156,16 @@ def _select_projects(
137
156
  project_filters: list[str],
138
157
  exclude_filters: list[str],
139
158
  ) -> list[Project]:
140
- """Discover projects and apply the include/exclude filters."""
159
+ """Discover projects and apply the include/exclude filters.
160
+
161
+ Returns:
162
+ The projects to mirror; empty, after saying so, if none are.
163
+ """
141
164
  all_projects, _discovery_stats = cli_hooks.discover_projects(config)
142
165
 
143
166
  if not all_projects:
144
167
  console.print("[yellow]No projects found on Gerrit server[/yellow]")
145
- raise typer.Exit(0)
168
+ return []
146
169
 
147
170
  # Filter projects by include/exclude patterns
148
171
  if project_filters or exclude_filters:
@@ -156,7 +179,7 @@ def _select_projects(
156
179
 
157
180
  if not projects_to_mirror:
158
181
  console.print("[yellow]No projects matched the specified filters[/yellow]")
159
- raise typer.Exit(0)
182
+ return []
160
183
 
161
184
  if not request.quiet:
162
185
  console.print(