github2gerrit 1.0.5__tar.gz → 1.0.6__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 (120) hide show
  1. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/.pre-commit-config.yaml +3 -3
  2. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/PKG-INFO +2 -1
  3. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/action.yaml +1 -1
  4. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/pyproject.toml +3 -0
  5. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/core.py +117 -1
  6. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/pr_content_filter.py +19 -21
  7. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_change_id_deduplication.py +1 -1
  8. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_cli_outputs_file.py +1 -1
  9. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_cli_url_and_dryrun.py +2 -2
  10. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_close_pr_policy.py +1 -3
  11. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_prepare_commits.py +1 -1
  12. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_shallow_clone.py +339 -0
  13. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_gerrit_change_id_footer.py +1 -1
  14. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_gitutils_helpers.py +1 -1
  15. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/uv.lock +93 -86
  16. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/.editorconfig +0 -0
  17. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/.gitignore +0 -0
  18. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/.gitlint +0 -0
  19. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/.markdownlint.yaml +0 -0
  20. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/.readthedocs.yml +0 -0
  21. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/.yamllint +0 -0
  22. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/LICENSE +0 -0
  23. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/LICENSES/Apache-2.0.txt +0 -0
  24. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/README.md +0 -0
  25. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/REUSE.toml +0 -0
  26. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/docs/COMPOSITE_ACTION_TESTING.md +0 -0
  27. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/docs/PR_UPDATE_IMPLEMENTATION.md +0 -0
  28. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/docs/RELEASE-v0.2.0.md +0 -0
  29. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/docs/github2gerrit_token_permissions_classic.png +0 -0
  30. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/sitecustomize.py +0 -0
  31. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/__init__.py +0 -0
  32. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/cli.py +0 -0
  33. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/commit_normalization.py +0 -0
  34. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/config.py +0 -0
  35. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/constants.py +0 -0
  36. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/duplicate_detection.py +0 -0
  37. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/error_codes.py +0 -0
  38. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/external_api.py +0 -0
  39. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/gerrit_pr_closer.py +0 -0
  40. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/gerrit_query.py +0 -0
  41. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/gerrit_rest.py +0 -0
  42. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/gerrit_urls.py +0 -0
  43. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/github_api.py +0 -0
  44. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/gitutils.py +0 -0
  45. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/mapping_comment.py +0 -0
  46. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/models.py +0 -0
  47. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/netrc.py +0 -0
  48. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/orchestrator/__init__.py +0 -0
  49. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  50. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/reconcile_matcher.py +0 -0
  51. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/rich_display.py +0 -0
  52. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/rich_logging.py +0 -0
  53. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/similarity.py +0 -0
  54. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/ssh_agent_setup.py +0 -0
  55. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/ssh_common.py +0 -0
  56. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/ssh_config_parser.py +0 -0
  57. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/ssh_discovery.py +0 -0
  58. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/trailers.py +0 -0
  59. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/src/github2gerrit/utils.py +0 -0
  60. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/conftest.py +0 -0
  61. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/fixtures/__init__.py +0 -0
  62. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/fixtures/make_repo.py +0 -0
  63. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/fixtures/ssh_config_samples.py +0 -0
  64. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_action_environment_mapping.py +0 -0
  65. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_action_outputs.py +0 -0
  66. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_action_pr_number_handling.py +0 -0
  67. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_action_step_validation.py +0 -0
  68. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_automation_only.py +0 -0
  69. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_cli.py +0 -0
  70. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_cli_helpers.py +0 -0
  71. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_cli_netrc_options.py +0 -0
  72. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_commit_normalization.py +0 -0
  73. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_composite_action_coverage.py +0 -0
  74. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_config_and_reviewers.py +0 -0
  75. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_config_helpers.py +0 -0
  76. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_config_and_errors.py +0 -0
  77. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_gerrit_backref_comment.py +0 -0
  78. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_gerrit_push_errors.py +0 -0
  79. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_gerrit_rest_results.py +0 -0
  80. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_integration_fixture_repo.py +0 -0
  81. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_ssh_setup.py +0 -0
  82. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_core_ssrf_protection.py +0 -0
  83. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_duplicate_detection.py +0 -0
  84. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_email_case_normalization.py +0 -0
  85. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_error_codes.py +0 -0
  86. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_external_api_framework.py +0 -0
  87. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_force_flag_cli.py +0 -0
  88. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_gerrit_change_status_checks.py +0 -0
  89. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_gerrit_pr_closer.py +0 -0
  90. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_gerrit_rest_client.py +0 -0
  91. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_gerrit_urls.py +0 -0
  92. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_gerrit_urls_more.py +0 -0
  93. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_ghe_and_gitreview_args.py +0 -0
  94. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_github_api_error_handling.py +0 -0
  95. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_github_api_helpers.py +0 -0
  96. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_github_api_retry_and_helpers.py +0 -0
  97. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_mapping_comment_additional.py +0 -0
  98. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  99. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_metadata_and_reconciliation.py +0 -0
  100. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_metadata_trailer_separation_bug.py +0 -0
  101. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_misc_small_coverage.py +0 -0
  102. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_netrc.py +0 -0
  103. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_orphan_rest_side_effects.py +0 -0
  104. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_pr_content_filter.py +0 -0
  105. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_pr_content_filter_integration.py +0 -0
  106. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_pr_update_detection.py +0 -0
  107. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_reconciliation_extracted_module.py +0 -0
  108. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  109. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_reconciliation_scenarios.py +0 -0
  110. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_ssh_agent.py +0 -0
  111. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_ssh_agent_ownership.py +0 -0
  112. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_ssh_artifact_prevention.py +0 -0
  113. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_ssh_common.py +0 -0
  114. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_ssh_discovery.py +0 -0
  115. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_ssh_discovery_dry_run.py +0 -0
  116. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_trailers_additional.py +0 -0
  117. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_url_parser.py +0 -0
  118. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/test_utils.py +0 -0
  119. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/unit/test_config_integration.py +0 -0
  120. {github2gerrit-1.0.5 → github2gerrit-1.0.6}/tests/unit/test_ssh_config_parser.py +0 -0
@@ -59,7 +59,7 @@ repos:
59
59
  types: [yaml]
60
60
 
61
61
  - repo: https://github.com/astral-sh/ruff-pre-commit
62
- rev: 45ef068da5f21267bb2a7ec4a623092959f09ce5 # frozen: v0.14.14
62
+ rev: fa93bc3224c614a0e9786d3e2d3d48edcca246eb # frozen: v0.15.1
63
63
  hooks:
64
64
  - id: ruff
65
65
  files: ^(src|scripts|tests)/.+\.py$
@@ -110,7 +110,7 @@ repos:
110
110
  # Replaces: https://github.com/rhysd/actionlint
111
111
  # Permits actionlint to run both locally and with precommit.ci/GitHub
112
112
  - repo: https://github.com/Mateusz-Grzelinski/actionlint-py
113
- rev: 85c37735ea69e5baf0681530e57e63deee0ce733 # frozen: v1.7.10.24
113
+ rev: 694e2c0dfb4253d51f3c6c54b8f9fec0a16764dc # frozen: v1.7.11.24
114
114
  hooks:
115
115
  - id: actionlint
116
116
 
@@ -121,7 +121,7 @@ repos:
121
121
  - id: codespell
122
122
 
123
123
  - repo: https://github.com/python-jsonschema/check-jsonschema
124
- rev: ccf21790019848af3eb4464be2a9d5efed6358f3 # frozen: 0.36.1
124
+ rev: ec368acd16deee9c560c105ab6d27db4ee19a5ec # frozen: 0.36.2
125
125
  hooks:
126
126
  - id: check-github-actions
127
127
  - id: check-github-workflows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 1.0.5
3
+ Version: 1.0.6
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit-action
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit-action
@@ -22,6 +22,7 @@ Classifier: Topic :: Software Development :: Build Tools
22
22
  Classifier: Topic :: Software Development :: Version Control
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.11
25
+ Requires-Dist: cryptography>=46.0.5
25
26
  Requires-Dist: git-review>=2.5.0
26
27
  Requires-Dist: pygerrit2>=2.0.15
27
28
  Requires-Dist: pygithub>=2.8.1
@@ -164,7 +164,7 @@ runs:
164
164
 
165
165
  - name: "Setup uv"
166
166
  # yamllint disable-line rule:line-length
167
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
167
+ uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
168
168
  with:
169
169
  enable-cache: false
170
170
 
@@ -50,6 +50,9 @@ dependencies = [
50
50
 
51
51
  # Security: Fix CVE-2026-21441 (decompression-bomb vulnerability)
52
52
  "urllib3>=2.6.3",
53
+
54
+ # Security: Fix CVE-2026-26007 (SECT curve subgroup attack)
55
+ "cryptography>=46.0.5",
53
56
  ]
54
57
 
55
58
  [project.urls]
@@ -2739,6 +2739,113 @@ class Orchestrator:
2739
2739
  run_cmd(cmd, cwd=self.workspace)
2740
2740
  log.info("Checkout succeeded after full unshallow")
2741
2741
 
2742
+ def _merge_squash_with_unshallow_fallback(self, head_sha: str) -> None:
2743
+ """Perform git merge --squash with graduated deepening fallback.
2744
+
2745
+ If the initial merge fails because the shallow clone lacks a common
2746
+ ancestor between the base and head (causing "refusing to merge
2747
+ unrelated histories"), this method will:
2748
+ 1. First try to deepen the repository (fetch 100 more commits)
2749
+ 2. If that fails, fully unshallow the repository
2750
+ 3. Retry the merge after each attempt
2751
+
2752
+ This mirrors the graduated approach used by
2753
+ ``_checkout_with_unshallow_fallback`` but for merge operations,
2754
+ which are equally susceptible to shallow clone limitations.
2755
+
2756
+ Args:
2757
+ head_sha: The SHA to merge (PR head commit)
2758
+
2759
+ Raises:
2760
+ CommandError: If merge fails even after unshallowing, or if
2761
+ the failure is not related to shallow clone history
2762
+ """
2763
+ merge_cmd = ["git", "merge", "--squash", head_sha]
2764
+
2765
+ merge_exc: CommandError | None = None
2766
+ try:
2767
+ run_cmd(merge_cmd, cwd=self.workspace)
2768
+ except CommandError as exc:
2769
+ merge_exc = exc
2770
+
2771
+ if merge_exc is None:
2772
+ return # Success on first attempt
2773
+
2774
+ # Check if the failure is due to unrelated histories in a shallow clone
2775
+ error_str = str(merge_exc).lower()
2776
+ stderr_str = (merge_exc.stderr or "").lower()
2777
+ combined = f"{error_str} {stderr_str}"
2778
+
2779
+ is_unrelated_histories = (
2780
+ "refusing to merge unrelated histories" in combined
2781
+ or "unrelated histories" in combined
2782
+ or "no common ancestor" in combined
2783
+ )
2784
+
2785
+ if not is_unrelated_histories or not self._is_shallow_clone():
2786
+ # Not a shallow clone issue — let the caller handle the error
2787
+ raise merge_exc
2788
+
2789
+ log.warning(
2790
+ "Merge --squash failed due to unrelated histories in shallow "
2791
+ "clone. Attempting graduated deepening to recover..."
2792
+ )
2793
+
2794
+ # Step 1: Try deepening first (cheaper than full unshallow)
2795
+ if self._deepen_repository(depth=100):
2796
+ log.debug("Retrying merge --squash after deepening...")
2797
+ # Reset the failed merge state before retrying
2798
+ run_cmd(
2799
+ ["git", "merge", "--abort"],
2800
+ cwd=self.workspace,
2801
+ check=False,
2802
+ )
2803
+ try:
2804
+ run_cmd(merge_cmd, cwd=self.workspace)
2805
+ except CommandError as deepen_exc:
2806
+ log.debug(
2807
+ "Merge --squash still failed after deepening: %s",
2808
+ deepen_exc,
2809
+ )
2810
+ # Re-check whether this is still a shallow-history problem;
2811
+ # if the error has changed (e.g. real merge conflict), an
2812
+ # expensive full unshallow cannot help — propagate immediately.
2813
+ deepen_combined = (
2814
+ f"{deepen_exc} {deepen_exc.stderr or ''}".lower()
2815
+ )
2816
+ deepen_is_unrelated = (
2817
+ "refusing to merge unrelated histories" in deepen_combined
2818
+ or "unrelated histories" in deepen_combined
2819
+ or "no common ancestor" in deepen_combined
2820
+ )
2821
+ if not deepen_is_unrelated:
2822
+ raise
2823
+ else:
2824
+ log.info("Merge --squash succeeded after deepening repository")
2825
+ return
2826
+
2827
+ # Step 2: Full unshallow as last resort
2828
+ log.info(
2829
+ "Deepening insufficient, performing full unshallow for merge..."
2830
+ )
2831
+ # Reset the failed merge state before retrying
2832
+ run_cmd(
2833
+ ["git", "merge", "--abort"],
2834
+ cwd=self.workspace,
2835
+ check=False,
2836
+ )
2837
+ if not self._unshallow_repository():
2838
+ log.error(
2839
+ "Failed to unshallow repository. Cannot merge SHA: %s",
2840
+ head_sha,
2841
+ )
2842
+ raise merge_exc
2843
+
2844
+ # Retry the merge after full unshallow
2845
+ log.debug("Retrying merge --squash after full unshallow...")
2846
+ run_cmd(merge_cmd, cwd=self.workspace)
2847
+ log.info("Merge --squash succeeded after full unshallow")
2848
+
2742
2849
  def _cleanup_ssh(self) -> None:
2743
2850
  """Clean up temporary SSH files created by this tool.
2744
2851
 
@@ -3103,6 +3210,15 @@ class Orchestrator:
3103
3210
 
3104
3211
  except Exception as debug_exc:
3105
3212
  log.warning("Failed to analyze merge situation: %s", debug_exc)
3213
+ # Proactively deepen if merge-base fails in a shallow clone,
3214
+ # as this strongly indicates the shallow history is insufficient
3215
+ # for the upcoming merge --squash operation.
3216
+ if self._is_shallow_clone():
3217
+ log.info(
3218
+ "merge-base failed in shallow clone — proactively "
3219
+ "deepening repository to improve merge success chances"
3220
+ )
3221
+ self._deepen_repository(depth=100)
3106
3222
 
3107
3223
  self._checkout_with_unshallow_fallback(
3108
3224
  branch_name=tmp_branch,
@@ -3134,7 +3250,7 @@ class Orchestrator:
3134
3250
 
3135
3251
  log.debug("About to run: git merge --squash %s", head_sha)
3136
3252
  try:
3137
- run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
3253
+ self._merge_squash_with_unshallow_fallback(head_sha)
3138
3254
  except CommandError as merge_exc:
3139
3255
  # Enhanced error handling for git merge failures
3140
3256
  error_details = self._analyze_merge_failure(
@@ -44,27 +44,6 @@ _MULTIPLE_NEWLINES_PATTERN = re.compile(r"\n{3,}")
44
44
  _EMOJI_PATTERN = re.compile(r":[a-z_]+:") # GitHub emoji codes like :sparkles:
45
45
 
46
46
 
47
- @dataclass
48
- class FilterConfig:
49
- """Configuration for PR content filtering."""
50
-
51
- # Global options
52
- enabled: bool = True
53
- remove_emoji_codes: bool = True
54
- deduplicate_title_in_body: bool = True
55
-
56
- # Author-specific filtering
57
- author_rules: dict[str, str] = field(default_factory=dict)
58
-
59
- # Rule-specific configurations
60
- dependabot_config: DependabotConfig = field(
61
- default_factory=lambda: DependabotConfig()
62
- )
63
- precommit_config: PrecommitConfig = field(
64
- default_factory=lambda: PrecommitConfig()
65
- )
66
-
67
-
68
47
  @dataclass
69
48
  class DependabotConfig:
70
49
  """Configuration for Dependabot PR filtering."""
@@ -85,6 +64,25 @@ class PrecommitConfig:
85
64
  # Future: add pre-commit.ci specific options
86
65
 
87
66
 
67
+ @dataclass
68
+ class FilterConfig:
69
+ """Configuration for PR content filtering."""
70
+
71
+ # Global options
72
+ enabled: bool = True
73
+ remove_emoji_codes: bool = True
74
+ deduplicate_title_in_body: bool = True
75
+
76
+ # Author-specific filtering
77
+ author_rules: dict[str, str] = field(default_factory=dict)
78
+
79
+ # Rule-specific configurations
80
+ dependabot_config: DependabotConfig = field(
81
+ default_factory=DependabotConfig
82
+ )
83
+ precommit_config: PrecommitConfig = field(default_factory=PrecommitConfig)
84
+
85
+
88
86
  class FilterRule(ABC):
89
87
  """Abstract base class for PR content filtering rules."""
90
88
 
@@ -275,7 +275,7 @@ Change-Id: {reused_change_id}
275
275
  monkeypatch.setattr("github2gerrit.core.run_cmd", mock_run_cmd)
276
276
 
277
277
  # Mock GitHub API for Change-ID reuse
278
- monkeypatch.setattr("github2gerrit.core.build_client", lambda: object())
278
+ monkeypatch.setattr("github2gerrit.core.build_client", object)
279
279
  monkeypatch.setattr(
280
280
  "github2gerrit.core.get_repo_from_env", lambda _: object()
281
281
  )
@@ -232,7 +232,7 @@ def test_multi_pr_url_mode_writes_aggregated_outputs(
232
232
  def __init__(self, number: int) -> None:
233
233
  self.number = number
234
234
 
235
- monkeypatch.setattr(cli_mod, "build_client", lambda: object())
235
+ monkeypatch.setattr(cli_mod, "build_client", object)
236
236
  monkeypatch.setattr(cli_mod, "get_repo_from_env", lambda _client: object())
237
237
  monkeypatch.setattr(
238
238
  cli_mod,
@@ -168,7 +168,7 @@ def test_repo_url_dry_run_invokes_for_each_open_pr(
168
168
  monkeypatch.setattr(cli_mod, "Orchestrator", _DummyOrchestrator)
169
169
 
170
170
  # Patch PyGithub wrapper functions used by CLI bulk path
171
- monkeypatch.setattr(cli_mod, "build_client", lambda: object())
171
+ monkeypatch.setattr(cli_mod, "build_client", object)
172
172
  monkeypatch.setattr(cli_mod, "get_repo_from_env", lambda _client: object())
173
173
  monkeypatch.setattr(
174
174
  cli_mod, "iter_open_pulls", lambda _repo: iter(dummy_prs)
@@ -212,7 +212,7 @@ def test_url_mode_sets_environment_for_config_resolution(
212
212
  monkeypatch.setattr(cli_mod, "Orchestrator", _DummyOrchestrator)
213
213
 
214
214
  # Minimal patches for bulk flow
215
- monkeypatch.setattr(cli_mod, "build_client", lambda: object())
215
+ monkeypatch.setattr(cli_mod, "build_client", object)
216
216
  monkeypatch.setattr(cli_mod, "get_repo_from_env", lambda _client: object())
217
217
  monkeypatch.setattr(
218
218
  cli_mod,
@@ -102,9 +102,7 @@ def test_close_pr_invoked_for_pull_request_target_event(
102
102
  self.closed_state: str | None = None
103
103
 
104
104
  # Patch the GitHub helper functions used by the close path
105
- monkeypatch.setattr(
106
- "github2gerrit.core.build_client", lambda: DummyClient()
107
- )
105
+ monkeypatch.setattr("github2gerrit.core.build_client", DummyClient)
108
106
  monkeypatch.setattr(
109
107
  "github2gerrit.core.get_repo_from_env", lambda _c: DummyRepo()
110
108
  )
@@ -237,7 +237,7 @@ def test_prepare_squashed_commit_reuses_change_id_from_comments(
237
237
  monkeypatch.setattr("github2gerrit.core.run_cmd", fake_run_cmd)
238
238
 
239
239
  # GitHub API helpers used to fetch PR comments
240
- monkeypatch.setattr("github2gerrit.core.build_client", lambda: object())
240
+ monkeypatch.setattr("github2gerrit.core.build_client", object)
241
241
  monkeypatch.setattr(
242
242
  "github2gerrit.core.get_repo_from_env", lambda _c: object()
243
243
  )
@@ -485,3 +485,342 @@ class TestEnsureWorkspacePrepared:
485
485
  # Should only have fetched once
486
486
  fetch_calls = [c for c in call_log if c[:2] == ["git", "fetch"]]
487
487
  assert len(fetch_calls) == 1
488
+
489
+
490
+ class TestMergeSquashWithUnshallowFallback:
491
+ """Tests for _merge_squash_with_unshallow_fallback method."""
492
+
493
+ def test_merge_success_first_attempt(self, tmp_path: Path) -> None:
494
+ """Merge succeeds on first attempt without any deepening."""
495
+ git_dir = tmp_path / ".git"
496
+ git_dir.mkdir()
497
+
498
+ call_log: list[list[str]] = []
499
+
500
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
501
+ call_log.append(cmd)
502
+ return CommandResult(returncode=0, stdout="", stderr="")
503
+
504
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
505
+ orch = Orchestrator(workspace=tmp_path)
506
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
507
+
508
+ # Should have called merge --squash exactly once
509
+ merge_calls = [c for c in call_log if "merge" in c and "--squash" in c]
510
+ assert len(merge_calls) == 1
511
+ assert merge_calls[0] == ["git", "merge", "--squash", "abc123"]
512
+
513
+ # No deepen or unshallow calls
514
+ deepen_calls = [c for c in call_log if "--deepen=" in str(c)]
515
+ assert len(deepen_calls) == 0
516
+ unshallow_calls = [c for c in call_log if "--unshallow" in c]
517
+ assert len(unshallow_calls) == 0
518
+
519
+ def test_merge_fails_unrelated_histories_deepen_succeeds(
520
+ self, tmp_path: Path
521
+ ) -> None:
522
+ """Merge fails with unrelated histories, deepen fixes it."""
523
+ git_dir = tmp_path / ".git"
524
+ git_dir.mkdir()
525
+ (git_dir / "shallow").touch()
526
+
527
+ merge_attempts = [0]
528
+
529
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
530
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
531
+ merge_attempts[0] += 1
532
+ if merge_attempts[0] == 1:
533
+ raise CommandError(
534
+ "fatal: refusing to merge unrelated histories",
535
+ returncode=128,
536
+ stderr="fatal: refusing to merge unrelated histories",
537
+ )
538
+ return CommandResult(returncode=0, stdout="", stderr="")
539
+ if cmd[:2] == ["git", "merge"] and "--abort" in cmd:
540
+ return CommandResult(returncode=0, stdout="", stderr="")
541
+ if "--deepen=" in str(cmd):
542
+ return CommandResult(returncode=0, stdout="", stderr="")
543
+ if "is-shallow-repository" in cmd:
544
+ return CommandResult(returncode=0, stdout="true\n", stderr="")
545
+ return CommandResult(returncode=0, stdout="", stderr="")
546
+
547
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
548
+ orch = Orchestrator(workspace=tmp_path)
549
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
550
+
551
+ # Should have attempted merge twice (before and after deepen)
552
+ assert merge_attempts[0] == 2
553
+
554
+ def test_merge_fails_unrelated_histories_deepen_insufficient_unshallow_succeeds(
555
+ self, tmp_path: Path
556
+ ) -> None:
557
+ """Merge fails, deepen insufficient, full unshallow fixes it."""
558
+ git_dir = tmp_path / ".git"
559
+ git_dir.mkdir()
560
+ (git_dir / "shallow").touch()
561
+
562
+ merge_attempts = [0]
563
+
564
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
565
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
566
+ merge_attempts[0] += 1
567
+ if merge_attempts[0] <= 2:
568
+ raise CommandError(
569
+ "fatal: refusing to merge unrelated histories",
570
+ returncode=128,
571
+ stderr="fatal: refusing to merge unrelated histories",
572
+ )
573
+ return CommandResult(returncode=0, stdout="", stderr="")
574
+ if cmd[:2] == ["git", "merge"] and "--abort" in cmd:
575
+ return CommandResult(returncode=0, stdout="", stderr="")
576
+ if "--deepen=" in str(cmd):
577
+ return CommandResult(returncode=0, stdout="", stderr="")
578
+ if cmd[:3] == ["git", "fetch", "--unshallow"]:
579
+ return CommandResult(returncode=0, stdout="", stderr="")
580
+ if "is-shallow-repository" in cmd:
581
+ return CommandResult(returncode=0, stdout="true\n", stderr="")
582
+ return CommandResult(returncode=0, stdout="", stderr="")
583
+
584
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
585
+ orch = Orchestrator(workspace=tmp_path)
586
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
587
+
588
+ # Should have attempted merge 3 times:
589
+ # 1. Initial (fail), 2. After deepen (fail), 3. After unshallow (success)
590
+ assert merge_attempts[0] == 3
591
+
592
+ def test_merge_fails_non_shallow_error_raises_immediately(
593
+ self, tmp_path: Path
594
+ ) -> None:
595
+ """Merge fails with non-shallow error, raises immediately."""
596
+ git_dir = tmp_path / ".git"
597
+ git_dir.mkdir()
598
+
599
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
600
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
601
+ raise CommandError(
602
+ "Merge conflict in file.txt",
603
+ returncode=1,
604
+ stderr="CONFLICT (content): Merge conflict in file.txt",
605
+ )
606
+ return CommandResult(returncode=0, stdout="", stderr="")
607
+
608
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
609
+ orch = Orchestrator(workspace=tmp_path)
610
+ with pytest.raises(CommandError) as exc_info:
611
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
612
+
613
+ assert "conflict" in str(exc_info.value).lower()
614
+
615
+ def test_merge_fails_unrelated_histories_not_shallow_raises(
616
+ self, tmp_path: Path
617
+ ) -> None:
618
+ """Unrelated histories in a non-shallow clone raises immediately."""
619
+ git_dir = tmp_path / ".git"
620
+ git_dir.mkdir()
621
+ # No shallow file — not a shallow clone
622
+
623
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
624
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
625
+ raise CommandError(
626
+ "fatal: refusing to merge unrelated histories",
627
+ returncode=128,
628
+ stderr="fatal: refusing to merge unrelated histories",
629
+ )
630
+ if "is-shallow-repository" in cmd:
631
+ return CommandResult(returncode=0, stdout="false\n", stderr="")
632
+ return CommandResult(returncode=0, stdout="", stderr="")
633
+
634
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
635
+ orch = Orchestrator(workspace=tmp_path)
636
+ with pytest.raises(CommandError) as exc_info:
637
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
638
+
639
+ assert "unrelated histories" in str(exc_info.value).lower()
640
+
641
+ def test_merge_fails_all_recovery_fails_raises(
642
+ self, tmp_path: Path
643
+ ) -> None:
644
+ """Merge fails, deepen and unshallow both fail, raises original error."""
645
+ git_dir = tmp_path / ".git"
646
+ git_dir.mkdir()
647
+ (git_dir / "shallow").touch()
648
+
649
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
650
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
651
+ raise CommandError(
652
+ "fatal: refusing to merge unrelated histories",
653
+ returncode=128,
654
+ stderr="fatal: refusing to merge unrelated histories",
655
+ )
656
+ if cmd[:2] == ["git", "merge"] and "--abort" in cmd:
657
+ return CommandResult(returncode=0, stdout="", stderr="")
658
+ if "--deepen=" in str(cmd):
659
+ raise CommandError("deepen failed", returncode=1)
660
+ if cmd[:3] == ["git", "fetch", "--unshallow"]:
661
+ raise CommandError("unshallow failed", returncode=1)
662
+ if "is-shallow-repository" in cmd:
663
+ return CommandResult(returncode=0, stdout="true\n", stderr="")
664
+ return CommandResult(returncode=0, stdout="", stderr="")
665
+
666
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
667
+ orch = Orchestrator(workspace=tmp_path)
668
+ with pytest.raises(CommandError) as exc_info:
669
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
670
+
671
+ assert "unrelated histories" in str(exc_info.value).lower()
672
+
673
+ def test_merge_abort_called_before_retry(self, tmp_path: Path) -> None:
674
+ """Ensure git merge --abort is called before retrying merge."""
675
+ git_dir = tmp_path / ".git"
676
+ git_dir.mkdir()
677
+ (git_dir / "shallow").touch()
678
+
679
+ call_log: list[list[str]] = []
680
+ merge_attempts = [0]
681
+
682
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
683
+ call_log.append(list(cmd))
684
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
685
+ merge_attempts[0] += 1
686
+ if merge_attempts[0] == 1:
687
+ raise CommandError(
688
+ "fatal: refusing to merge unrelated histories",
689
+ returncode=128,
690
+ stderr="fatal: refusing to merge unrelated histories",
691
+ )
692
+ return CommandResult(returncode=0, stdout="", stderr="")
693
+ if cmd[:2] == ["git", "merge"] and "--abort" in cmd:
694
+ return CommandResult(returncode=0, stdout="", stderr="")
695
+ if "--deepen=" in str(cmd):
696
+ return CommandResult(returncode=0, stdout="", stderr="")
697
+ if "is-shallow-repository" in cmd:
698
+ return CommandResult(returncode=0, stdout="true\n", stderr="")
699
+ return CommandResult(returncode=0, stdout="", stderr="")
700
+
701
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
702
+ orch = Orchestrator(workspace=tmp_path)
703
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
704
+
705
+ # Find the position of the abort call and the second merge call
706
+ abort_indices = [
707
+ i
708
+ for i, c in enumerate(call_log)
709
+ if c[:2] == ["git", "merge"] and "--abort" in c
710
+ ]
711
+ second_merge_indices = [
712
+ i
713
+ for i, c in enumerate(call_log)
714
+ if c == ["git", "merge", "--squash", "abc123"]
715
+ ]
716
+ # abort should come before the second merge attempt
717
+ assert len(abort_indices) >= 1
718
+ assert len(second_merge_indices) == 2
719
+ assert abort_indices[0] < second_merge_indices[1]
720
+
721
+ @pytest.mark.parametrize(
722
+ "error_message,stderr_message",
723
+ [
724
+ (
725
+ "fatal: refusing to merge unrelated histories",
726
+ "fatal: refusing to merge unrelated histories",
727
+ ),
728
+ (
729
+ "merge failed: unrelated histories",
730
+ "unrelated histories detected",
731
+ ),
732
+ (
733
+ "no common ancestor found",
734
+ "fatal: no common ancestor",
735
+ ),
736
+ ],
737
+ )
738
+ def test_recognizes_unrelated_history_errors(
739
+ self,
740
+ tmp_path: Path,
741
+ error_message: str,
742
+ stderr_message: str,
743
+ ) -> None:
744
+ """All variations of unrelated history errors trigger deepening."""
745
+ git_dir = tmp_path / ".git"
746
+ git_dir.mkdir()
747
+ (git_dir / "shallow").touch()
748
+
749
+ merge_attempts = [0]
750
+
751
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
752
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
753
+ merge_attempts[0] += 1
754
+ if merge_attempts[0] == 1:
755
+ raise CommandError(
756
+ error_message,
757
+ returncode=128,
758
+ stderr=stderr_message,
759
+ )
760
+ return CommandResult(returncode=0, stdout="", stderr="")
761
+ if cmd[:2] == ["git", "merge"] and "--abort" in cmd:
762
+ return CommandResult(returncode=0, stdout="", stderr="")
763
+ if "--deepen=" in str(cmd):
764
+ return CommandResult(returncode=0, stdout="", stderr="")
765
+ if "is-shallow-repository" in cmd:
766
+ return CommandResult(returncode=0, stdout="true\n", stderr="")
767
+ return CommandResult(returncode=0, stdout="", stderr="")
768
+
769
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
770
+ orch = Orchestrator(workspace=tmp_path)
771
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
772
+
773
+ # Should have attempted merge twice (before and after deepen)
774
+ assert merge_attempts[0] == 2
775
+
776
+ def test_deepen_changes_error_to_conflict_raises_without_unshallow(
777
+ self, tmp_path: Path
778
+ ) -> None:
779
+ """After deepen, if error changes from unrelated histories to a merge
780
+ conflict, raise immediately instead of attempting expensive unshallow."""
781
+ git_dir = tmp_path / ".git"
782
+ git_dir.mkdir()
783
+ (git_dir / "shallow").touch()
784
+
785
+ merge_attempts = [0]
786
+ call_log: list[list[str]] = []
787
+
788
+ def fake_run_cmd(cmd: list[str], **kwargs: Any) -> CommandResult:
789
+ call_log.append(list(cmd))
790
+ if cmd[:2] == ["git", "merge"] and "--squash" in cmd:
791
+ merge_attempts[0] += 1
792
+ if merge_attempts[0] == 1:
793
+ # First attempt: unrelated histories (shallow clone issue)
794
+ raise CommandError(
795
+ "fatal: refusing to merge unrelated histories",
796
+ returncode=128,
797
+ stderr="fatal: refusing to merge unrelated histories",
798
+ )
799
+ # Second attempt after deepen: now a real merge conflict
800
+ raise CommandError(
801
+ "Merge conflict in file.txt",
802
+ returncode=1,
803
+ stderr="CONFLICT (content): Merge conflict in file.txt",
804
+ )
805
+ if cmd[:2] == ["git", "merge"] and "--abort" in cmd:
806
+ return CommandResult(returncode=0, stdout="", stderr="")
807
+ if "--deepen=" in str(cmd):
808
+ return CommandResult(returncode=0, stdout="", stderr="")
809
+ if "is-shallow-repository" in cmd:
810
+ return CommandResult(returncode=0, stdout="true\n", stderr="")
811
+ return CommandResult(returncode=0, stdout="", stderr="")
812
+
813
+ with patch("github2gerrit.core.run_cmd", side_effect=fake_run_cmd):
814
+ orch = Orchestrator(workspace=tmp_path)
815
+ with pytest.raises(CommandError) as exc_info:
816
+ orch._merge_squash_with_unshallow_fallback(head_sha="abc123")
817
+
818
+ # Should have raised the conflict error, not the original
819
+ assert "conflict" in str(exc_info.value).lower()
820
+
821
+ # Should have attempted merge twice only (no third attempt after unshallow)
822
+ assert merge_attempts[0] == 2
823
+
824
+ # Should NOT have attempted unshallow
825
+ unshallow_calls = [c for c in call_log if "--unshallow" in c]
826
+ assert len(unshallow_calls) == 0
@@ -99,7 +99,7 @@ def test_apply_pr_title_body_preserves_change_id_footer(
99
99
  )
100
100
 
101
101
  # Stub GitHub API helpers used by _apply_pr_title_body_if_requested
102
- monkeypatch.setattr("github2gerrit.core.build_client", lambda: object())
102
+ monkeypatch.setattr("github2gerrit.core.build_client", object)
103
103
  monkeypatch.setattr(
104
104
  "github2gerrit.core.get_repo_from_env", lambda client: object()
105
105
  )