├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── deploy.yml │ ├── lint.yml │ └── main.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── RELEASING.md ├── cherry_picker ├── __init__.py ├── __main__.py ├── cherry_picker.py └── test_cherry_picker.py ├── pyproject.toml ├── pytest.ini └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | 3 | [report] 4 | # Regexes for lines to exclude from consideration 5 | exclude_lines = 6 | # Have to re-enable the standard pragma: 7 | pragma: no cover 8 | 9 | # Don't complain if non-runnable code isn't run: 10 | if __name__ == .__main__.: 11 | def cherry_pick_cli 12 | 13 | [run] 14 | omit = 15 | cherry_picker/__main__.py 16 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend-ignore = C408,E203,F841,W503 3 | max-complexity = 12 4 | max-line-length = 88 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | assignees: 9 | - "ezio-melotti" 10 | open-pull-requests-limit: 10 11 | 12 | # Maintain dependencies for Python 13 | - package-ecosystem: pip 14 | directory: "/" 15 | schedule: 16 | interval: monthly 17 | assignees: 18 | - "ezio-melotti" 19 | open-pull-requests-limit: 10 20 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build package 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: 8 | - published 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | FORCE_COLOR: 1 16 | 17 | jobs: 18 | # Always build & lint package. 19 | build-package: 20 | name: Build & verify package 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | persist-credentials: false 28 | 29 | - uses: hynek/build-and-inspect-python-package@v2 30 | 31 | # Publish to Test PyPI on every commit on main. 32 | release-test-pypi: 33 | name: Publish in-dev package to test.pypi.org 34 | if: | 35 | github.repository_owner == 'python' 36 | && github.event_name == 'push' 37 | && github.ref == 'refs/heads/main' 38 | runs-on: ubuntu-latest 39 | needs: build-package 40 | 41 | permissions: 42 | id-token: write 43 | 44 | steps: 45 | - name: Download packages built by build-and-inspect-python-package 46 | uses: actions/download-artifact@v4 47 | with: 48 | name: Packages 49 | path: dist 50 | 51 | - name: Publish to Test PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | with: 54 | repository-url: https://test.pypi.org/legacy/ 55 | 56 | # Publish to PyPI on GitHub Releases. 57 | release-pypi: 58 | name: Publish to PyPI 59 | # Only run for published releases. 60 | if: | 61 | github.repository_owner == 'python' 62 | && github.event.action == 'published' 63 | runs-on: ubuntu-latest 64 | needs: build-package 65 | 66 | permissions: 67 | id-token: write 68 | 69 | steps: 70 | - name: Download packages built by build-and-inspect-python-package 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: Packages 74 | path: dist 75 | 76 | - name: Publish to PyPI 77 | uses: pypa/gh-action-pypi-publish@release/v1 78 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | env: 6 | FORCE_COLOR: 1 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | persist-credentials: false 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.x" 22 | - uses: tox-dev/action-pre-commit-uv@v1 23 | - run: uvx safety check 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | test: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 18 | os: [windows-latest, macos-latest, ubuntu-latest] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | # fetch all branches and tags 24 | # ref actions/checkout#448 25 | fetch-depth: 0 26 | persist-credentials: false 27 | 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | allow-prereleases: true 33 | 34 | - name: Install uv 35 | uses: hynek/setup-cached-uv@v2 36 | 37 | - name: Run tests 38 | run: uvx --with tox-uv tox -e py 39 | 40 | - name: Upload coverage 41 | uses: codecov/codecov-action@v5 42 | with: 43 | flags: ${{ matrix.os }} 44 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} 45 | token: ${{ secrets.CODECOV_ORG_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs 3 | # Edit at https://www.gitignore.io/?templates=git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs 4 | 5 | ### Emacs ### 6 | # -*- mode: gitignore; -*- 7 | *~ 8 | \#*\# 9 | /.emacs.desktop 10 | /.emacs.desktop.lock 11 | *.elc 12 | auto-save-list 13 | tramp 14 | .\#* 15 | 16 | # Org-mode 17 | .org-id-locations 18 | *_archive 19 | 20 | # flymake-mode 21 | *_flymake.* 22 | 23 | # eshell files 24 | /eshell/history 25 | /eshell/lastdir 26 | 27 | # elpa packages 28 | /elpa/ 29 | 30 | # reftex files 31 | *.rel 32 | 33 | # AUCTeX auto folder 34 | /auto/ 35 | 36 | # cask packages 37 | .cask/ 38 | dist/ 39 | 40 | # Flycheck 41 | flycheck_*.el 42 | 43 | # server auth directory 44 | /server/ 45 | 46 | # projectiles files 47 | .projectile 48 | 49 | # directory configuration 50 | .dir-locals.el 51 | 52 | # network security 53 | /network-security.data 54 | 55 | 56 | ### Git ### 57 | # Created by git for backups. To disable backups in Git: 58 | # $ git config --global mergetool.keepBackup false 59 | *.orig 60 | 61 | # Created by git when using merge tools for conflicts 62 | *.BACKUP.* 63 | *.BASE.* 64 | *.LOCAL.* 65 | *.REMOTE.* 66 | *_BACKUP_*.txt 67 | *_BASE_*.txt 68 | *_LOCAL_*.txt 69 | *_REMOTE_*.txt 70 | 71 | ### JupyterNotebook ### 72 | .ipynb_checkpoints 73 | */.ipynb_checkpoints/* 74 | 75 | # Remove previous ipynb_checkpoints 76 | # git rm -r .ipynb_checkpoints/ 77 | # 78 | 79 | ### Linux ### 80 | 81 | # temporary files which can be created if a process still has a handle open of a deleted file 82 | .fuse_hidden* 83 | 84 | # KDE directory preferences 85 | .directory 86 | 87 | # Linux trash folder which might appear on any partition or disk 88 | .Trash-* 89 | 90 | # .nfs files are created when an open file is removed but is still being accessed 91 | .nfs* 92 | 93 | ### PyCharm+all ### 94 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 95 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 96 | 97 | # User-specific stuff 98 | .idea/**/workspace.xml 99 | .idea/**/tasks.xml 100 | .idea/**/usage.statistics.xml 101 | .idea/**/dictionaries 102 | .idea/**/shelf 103 | 104 | # Generated files 105 | .idea/**/contentModel.xml 106 | 107 | # Sensitive or high-churn files 108 | .idea/**/dataSources/ 109 | .idea/**/dataSources.ids 110 | .idea/**/dataSources.local.xml 111 | .idea/**/sqlDataSources.xml 112 | .idea/**/dynamic.xml 113 | .idea/**/uiDesigner.xml 114 | .idea/**/dbnavigator.xml 115 | 116 | # Gradle 117 | .idea/**/gradle.xml 118 | .idea/**/libraries 119 | 120 | # Gradle and Maven with auto-import 121 | # When using Gradle or Maven with auto-import, you should exclude module files, 122 | # since they will be recreated, and may cause churn. Uncomment if using 123 | # auto-import. 124 | # .idea/modules.xml 125 | # .idea/*.iml 126 | # .idea/modules 127 | 128 | # CMake 129 | cmake-build-*/ 130 | 131 | # Mongo Explorer plugin 132 | .idea/**/mongoSettings.xml 133 | 134 | # File-based project format 135 | *.iws 136 | 137 | # IntelliJ 138 | out/ 139 | 140 | # mpeltonen/sbt-idea plugin 141 | .idea_modules/ 142 | 143 | # JIRA plugin 144 | atlassian-ide-plugin.xml 145 | 146 | # Cursive Clojure plugin 147 | .idea/replstate.xml 148 | 149 | # Crashlytics plugin (for Android Studio and IntelliJ) 150 | com_crashlytics_export_strings.xml 151 | crashlytics.properties 152 | crashlytics-build.properties 153 | fabric.properties 154 | 155 | # Editor-based Rest Client 156 | .idea/httpRequests 157 | 158 | # Android studio 3.1+ serialized cache file 159 | .idea/caches/build_file_checksums.ser 160 | 161 | # JetBrains templates 162 | **___jb_tmp___ 163 | 164 | ### PyCharm+all Patch ### 165 | # Ignores the whole .idea folder and all .iml files 166 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 167 | 168 | .idea/ 169 | 170 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 171 | 172 | *.iml 173 | modules.xml 174 | .idea/misc.xml 175 | *.ipr 176 | 177 | # Sonarlint plugin 178 | .idea/sonarlint 179 | 180 | ### pydev ### 181 | .pydevproject 182 | 183 | ### Python ### 184 | # Byte-compiled / optimized / DLL files 185 | __pycache__/ 186 | *.py[cod] 187 | *$py.class 188 | 189 | # C extensions 190 | *.so 191 | 192 | # Distribution / packaging 193 | .Python 194 | build/ 195 | develop-eggs/ 196 | downloads/ 197 | eggs/ 198 | .eggs/ 199 | lib/ 200 | lib64/ 201 | parts/ 202 | sdist/ 203 | var/ 204 | wheels/ 205 | pip-wheel-metadata/ 206 | share/python-wheels/ 207 | *.egg-info/ 208 | .installed.cfg 209 | *.egg 210 | MANIFEST 211 | 212 | # PyInstaller 213 | # Usually these files are written by a python script from a template 214 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 215 | *.manifest 216 | *.spec 217 | 218 | # Installer logs 219 | pip-log.txt 220 | pip-delete-this-directory.txt 221 | 222 | # Unit test / coverage reports 223 | htmlcov/ 224 | .tox/ 225 | .nox/ 226 | .coverage 227 | .coverage.* 228 | .cache 229 | nosetests.xml 230 | coverage.xml 231 | *.cover 232 | .hypothesis/ 233 | .pytest_cache/ 234 | 235 | # Translations 236 | *.mo 237 | *.pot 238 | 239 | # Django stuff: 240 | *.log 241 | local_settings.py 242 | db.sqlite3 243 | 244 | # Flask stuff: 245 | instance/ 246 | .webassets-cache 247 | 248 | # Scrapy stuff: 249 | .scrapy 250 | 251 | # Sphinx documentation 252 | docs/_build/ 253 | 254 | # PyBuilder 255 | target/ 256 | 257 | # Jupyter Notebook 258 | 259 | # IPython 260 | profile_default/ 261 | ipython_config.py 262 | 263 | # pyenv 264 | .python-version 265 | 266 | # pipenv 267 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 268 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 269 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 270 | # install all needed dependencies. 271 | #Pipfile.lock 272 | 273 | # celery beat schedule file 274 | celerybeat-schedule 275 | 276 | # SageMath parsed files 277 | *.sage.py 278 | 279 | # Environments 280 | .env 281 | .venv 282 | env/ 283 | venv/ 284 | ENV/ 285 | env.bak/ 286 | venv.bak/ 287 | 288 | # Spyder project settings 289 | .spyderproject 290 | .spyproject 291 | 292 | # Rope project settings 293 | .ropeproject 294 | 295 | # mkdocs documentation 296 | /site 297 | 298 | # mypy 299 | .mypy_cache/ 300 | .dmypy.json 301 | dmypy.json 302 | 303 | # Pyre type checker 304 | .pyre/ 305 | 306 | ### Vim ### 307 | # Swap 308 | [._]*.s[a-v][a-z] 309 | [._]*.sw[a-p] 310 | [._]s[a-rt-v][a-z] 311 | [._]ss[a-gi-z] 312 | [._]sw[a-p] 313 | 314 | # Session 315 | Session.vim 316 | 317 | # Temporary 318 | .netrwhist 319 | # Auto-generated tag files 320 | tags 321 | # Persistent undo 322 | [._]*.un~ 323 | 324 | ### WebStorm ### 325 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 326 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 327 | 328 | # User-specific stuff 329 | 330 | # Generated files 331 | 332 | # Sensitive or high-churn files 333 | 334 | # Gradle 335 | 336 | # Gradle and Maven with auto-import 337 | # When using Gradle or Maven with auto-import, you should exclude module files, 338 | # since they will be recreated, and may cause churn. Uncomment if using 339 | # auto-import. 340 | # .idea/modules.xml 341 | # .idea/*.iml 342 | # .idea/modules 343 | 344 | # CMake 345 | 346 | # Mongo Explorer plugin 347 | 348 | # File-based project format 349 | 350 | # IntelliJ 351 | 352 | # mpeltonen/sbt-idea plugin 353 | 354 | # JIRA plugin 355 | 356 | # Cursive Clojure plugin 357 | 358 | # Crashlytics plugin (for Android Studio and IntelliJ) 359 | 360 | # Editor-based Rest Client 361 | 362 | # Android studio 3.1+ serialized cache file 363 | 364 | # JetBrains templates 365 | 366 | ### WebStorm Patch ### 367 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 368 | 369 | # *.iml 370 | # modules.xml 371 | # .idea/misc.xml 372 | # *.ipr 373 | 374 | # Sonarlint plugin 375 | 376 | ### Windows ### 377 | # Windows thumbnail cache files 378 | Thumbs.db 379 | ehthumbs.db 380 | ehthumbs_vista.db 381 | 382 | # Dump file 383 | *.stackdump 384 | 385 | # Folder config file 386 | [Dd]esktop.ini 387 | 388 | # Recycle Bin used on file shares 389 | $RECYCLE.BIN/ 390 | 391 | # Windows Installer files 392 | *.cab 393 | *.msi 394 | *.msix 395 | *.msm 396 | *.msp 397 | 398 | # Windows shortcuts 399 | *.lnk 400 | 401 | # End of https://www.gitignore.io/api/git,linux,pydev,python,windows,pycharm+all,jupyternotebook,vim,webstorm,emacs 402 | 403 | # hatch-vcs 404 | cherry_picker/_version.py 405 | 406 | # Ignore uv.lock 407 | uv.lock 408 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.6 4 | hooks: 5 | - id: ruff 6 | args: [--exit-non-zero-on-fix] 7 | 8 | - repo: https://github.com/psf/black-pre-commit-mirror 9 | rev: 24.10.0 10 | hooks: 11 | - id: black 12 | 13 | - repo: https://github.com/pre-commit/pre-commit-hooks 14 | rev: v5.0.0 15 | hooks: 16 | - id: check-added-large-files 17 | - id: check-case-conflict 18 | - id: check-executables-have-shebangs 19 | - id: check-merge-conflict 20 | - id: check-toml 21 | - id: check-yaml 22 | - id: debug-statements 23 | - id: end-of-file-fixer 24 | - id: forbid-submodules 25 | - id: trailing-whitespace 26 | 27 | - repo: https://github.com/python-jsonschema/check-jsonschema 28 | rev: 0.30.0 29 | hooks: 30 | - id: check-dependabot 31 | - id: check-github-workflows 32 | 33 | - repo: https://github.com/rhysd/actionlint 34 | rev: v1.7.6 35 | hooks: 36 | - id: actionlint 37 | 38 | - repo: https://github.com/pre-commit/mirrors-mypy 39 | rev: v1.14.1 40 | hooks: 41 | - id: mypy 42 | args: 43 | [ 44 | --ignore-missing-imports, 45 | --pretty, 46 | --show-error-codes, 47 | ., 48 | ] 49 | pass_filenames: false 50 | additional_dependencies: ["types-requests"] 51 | 52 | - repo: https://github.com/tox-dev/pyproject-fmt 53 | rev: v2.5.0 54 | hooks: 55 | - id: pyproject-fmt 56 | 57 | - repo: https://github.com/abravalheri/validate-pyproject 58 | rev: v0.23 59 | hooks: 60 | - id: validate-pyproject 61 | 62 | - repo: https://github.com/tox-dev/tox-ini-fmt 63 | rev: 1.4.1 64 | hooks: 65 | - id: tox-ini-fmt 66 | 67 | - repo: https://github.com/codespell-project/codespell 68 | rev: v2.3.0 69 | hooks: 70 | - id: codespell 71 | args: [--ignore-words-list=commitish] 72 | 73 | - repo: meta 74 | hooks: 75 | - id: check-hooks-apply 76 | - id: check-useless-excludes 77 | 78 | ci: 79 | autoupdate_schedule: quarterly 80 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.5.0 4 | 5 | * Add draft config option to Create Pull Request by @gopidesupavan in https://github.com/python/cherry-picker/pull/151 6 | * Better error message when cherry_picker is called in wrong state by @serhiy-storchaka in https://github.com/python/cherry-picker/pull/119 7 | * Bubble up error message by @dpr-0 in https://github.com/python/cherry-picker/pull/112 8 | * Acknowledge network issues on GitHub by @ambv in https://github.com/python/cherry-picker/pull/153 9 | * Ignore uv.lock file by @potiuk in https://github.com/python/cherry-picker/pull/149 10 | * Fix mypy pre-commit settings by @potiuk in https://github.com/python/cherry-picker/pull/148 11 | * Update CI config by @hugovk in https://github.com/python/cherry-picker/pull/144 12 | 13 | ## 2.4.0 14 | 15 | - Add support for Python 3.14 ([PR 145](https://github.com/python/cherry-picker/pull/145)) 16 | - Allow passing a base branch that doesn't have version info 17 | ([PR 70](https://github.com/python/cherry-picker/pull/70)) 18 | - This makes cherry-picker useful for projects other than CPython that don't 19 | have versioned branch names. 20 | 21 | ## 2.3.0 22 | 23 | - Add support for Python 3.13 24 | ([PR 127](https://github.com/python/cherry-picker/pull/127), 25 | [PR 134](https://github.com/python/cherry-picker/pull/134)) 26 | - Drop support for EOL Python 3.8 27 | ([PR 133](https://github.com/python/cherry-picker/pull/133), 28 | [PR 137](https://github.com/python/cherry-picker/pull/137)) 29 | - Resolve usernames when the remote ends with a trailing slash ([PR 110](https://github.com/python/cherry-picker/pull/110)) 30 | - Optimize `validate_sha()` with `--max-count=1` ([PR 111](https://github.com/python/cherry-picker/pull/111)) 31 | - Make # replacing more strict ([PR 115](https://github.com/python/cherry-picker/pull/115)) 32 | - Remove multiple commit prefixes ([PR 118](https://github.com/python/cherry-picker/pull/118)) 33 | - Handle whitespace when calculating usernames ([PR 132](https://github.com/python/cherry-picker/pull/132)) 34 | - Publish to PyPI using Trusted Publishers ([PR 94](https://github.com/python/cherry-picker/pull/94)) 35 | - Generate digital attestations for PyPI ([PEP 740](https://peps.python.org/pep-0740/)) 36 | ([PR 135](https://github.com/python/cherry-picker/pull/135)) 37 | 38 | ## 2.2.0 39 | 40 | - Add log messages 41 | - Fix for conflict handling, get the state correctly ([PR 88](https://github.com/python/cherry-picker/pull/88)) 42 | - Drop support for Python 3.7 ([PR 90](https://github.com/python/cherry-picker/pull/90)) 43 | 44 | ## 2.1.0 45 | 46 | - Mix fixes: #28, #29, #31, #32, #33, #34, #36 47 | 48 | ## 2.0.0 49 | 50 | - Support the `main` branch by default ([PR 23](https://github.com/python/cherry-picker/pull/23)). 51 | To use a different default branch, please configure it in the 52 | `.cherry-picker.toml` file. 53 | 54 | - Renamed `cherry-picker`'s own default branch to `main` 55 | 56 | ## 1.3.2 57 | 58 | - Use `--no-tags` option when fetching upstream ([PR 319](https://github.com/python/core-workflow/pull/319)) 59 | 60 | ## 1.3.1 61 | 62 | - Modernize cherry_picker's pyproject.toml file ([PR #316](https://github.com/python/core-workflow/pull/316)) 63 | 64 | - Remove the `BACKPORT_COMPLETE` state. Unset the states when backport is completed 65 | ([PR #315](https://github.com/python/core-workflow/pull/315)) 66 | 67 | - Run Travis CI test on Windows ([PR #311](https://github.com/python/core-workflow/pull/311)) 68 | 69 | ## 1.3.0 70 | 71 | - Implement state machine and storing reference to the config 72 | used at the beginning of the backport process using commit sha 73 | and a repo-local Git config. 74 | ([PR #295](https://github.com/python/core-workflow/pull/295)) 75 | 76 | ## 1.2.2 77 | 78 | - Relaxed click dependency ([PR #302](https://github.com/python/core-workflow/pull/302)) 79 | 80 | ## 1.2.1 81 | 82 | - Validate the branch name to operate on with `--continue` and fail early if the branch could not 83 | have been created by cherry_picker ([PR #266](https://github.com/python/core-workflow/pull/266)) 84 | 85 | - Bugfix: Allow `--continue` to support version branches that have dashes in them. This is 86 | a bugfix of the additional branch versioning schemes introduced in 1.2.0. 87 | ([PR #265](https://github.com/python/core-workflow/pull/265)). 88 | 89 | - Bugfix: Be explicit about the branch name on the remote to push the cherry pick to. This allows 90 | cherry_picker to work correctly when the user has a git push strategy other than the default 91 | configured ([PR #264](https://github.com/python/core-workflow/pull/264)). 92 | 93 | ## 1.2.0 94 | 95 | - Add `default_branch` configuration item. The default is `master`, which 96 | is the default branch for CPython. It can be configured to other branches like, 97 | `devel`, or `develop`. The default branch is the branch cherry_picker 98 | will return to after backporting ([PR #254](https://github.com/python/core-workflow/pull/254) 99 | and [Issue #250](https://github.com/python/core-workflow/issues/250)). 100 | 101 | - Support additional branch versioning schemes, such as `something-X.Y`, 102 | or `X.Y-somethingelse`. ([PR #253](https://github.com/python/core-workflow/pull/253) 103 | and [Issue #251](https://github.com/python/core-workflow/issues/251)). 104 | 105 | ## 1.1.1 106 | 107 | - Change the calls to `subprocess` to use lists instead of strings. This fixes 108 | the bug that affects users in Windows 109 | ([PR #238](https://github.com/python/core-workflow/pull/238)). 110 | 111 | ## 1.1.0 112 | 113 | - Add `fix_commit_msg` configuration item. Setting fix_commit_msg to `true` 114 | will replace the issue number in the commit message, from `#` to `GH-`. 115 | This is the default behavior for CPython. Other projects can opt out by 116 | setting it to `false` ([PR #233](https://github.com/python/core-workflow/pull/233) 117 | and [aiohttp issue #2853](https://github.com/aio-libs/aiohttp/issues/2853)). 118 | 119 | ## 1.0.0 120 | 121 | - Support configuration file by using `--config-path` option, or by adding 122 | `.cherry-picker.toml` file to the root of the project 123 | ([Issue #225](https://github.com/python/core-workflow/issues/225)) 124 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2016 Python Software Foundation 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cherry_picker 2 | 3 | [![PyPI version](https://img.shields.io/pypi/v/cherry-picker.svg?logo=pypi&logoColor=FFE873)](https://pypi.org/project/cherry-picker) 4 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/cherry-picker.svg?logo=python&logoColor=FFE873)](https://pypi.org/project/cherry-picker) 5 | [![tests](https://github.com/python/cherry-picker/actions/workflows/main.yml/badge.svg)](https://github.com/python/cherry-picker/actions/workflows/main.yml) 6 | 7 | Usage (from a cloned CPython directory): 8 | 9 | ``` 10 | Usage: cherry_picker [OPTIONS] [COMMIT_SHA1] [BRANCHES]... 11 | 12 | cherry-pick COMMIT_SHA1 into target BRANCHES. 13 | 14 | Options: 15 | --version Show the version and exit. 16 | --dry-run Prints out the commands, but not executed. 17 | --pr-remote REMOTE git remote to use for PR branches 18 | --upstream-remote REMOTE git remote to use for upstream branches 19 | --abort Abort current cherry-pick and clean up branch 20 | --continue Continue cherry-pick, push, and clean up branch 21 | --status Get the status of cherry-pick 22 | --push / --no-push Changes won't be pushed to remote 23 | --auto-pr / --no-auto-pr If auto PR is enabled, cherry-picker will 24 | automatically open a PR through API if GH_AUTH 25 | env var is set, or automatically open the PR 26 | creation page in the web browser otherwise. 27 | --config-path CONFIG-PATH Path to config file, .cherry_picker.toml from 28 | project root by default. You can prepend a colon- 29 | separated Git 'commitish' reference. 30 | -h, --help Show this message and exit. 31 | ``` 32 | 33 | ## About 34 | 35 | This tool is used to backport CPython changes from `main` into one or more 36 | of the maintenance branches (e.g. `3.12`, `3.11`). 37 | 38 | `cherry_picker` can be configured to backport other projects with similar 39 | workflow as CPython. See the configuration file options below for more details. 40 | 41 | The maintenance branch names should contain some sort of version number (`X.Y`). 42 | For example: `3.12`, `stable-3.12`, `1.5`, `1.5-lts`, are all supported branch 43 | names. 44 | 45 | It will prefix the commit message with the branch, e.g. `[3.12]`, and then 46 | open up the pull request page. 47 | 48 | Write tests using [pytest](https://docs.pytest.org/). 49 | 50 | 51 | ## Setup info 52 | 53 | ```console 54 | $ python3 -m venv venv 55 | $ source venv/bin/activate 56 | (venv) $ python -m pip install cherry_picker 57 | ``` 58 | 59 | The cherry-picking script assumes that if an `upstream` remote is defined, then 60 | it should be used as the source of upstream changes and as the base for 61 | cherry-pick branches. Otherwise, `origin` is used for that purpose. 62 | You can override this behavior with the `--upstream-remote` option 63 | (e.g. `--upstream-remote python` to use a remote named `python`). 64 | 65 | Verify that an `upstream` remote is set to the CPython repository: 66 | 67 | ```console 68 | $ git remote -v 69 | ... 70 | upstream https://github.com/python/cpython (fetch) 71 | upstream https://github.com/python/cpython (push) 72 | ``` 73 | 74 | If needed, create the `upstream` remote: 75 | 76 | ```console 77 | $ git remote add upstream https://github.com/python/cpython.git 78 | ``` 79 | 80 | By default, the PR branches used to submit pull requests back to the main 81 | repository are pushed to `origin`. If this is incorrect, then the correct 82 | remote will need be specified using the `--pr-remote` option (e.g. 83 | `--pr-remote pr` to use a remote named `pr`). 84 | 85 | 86 | ## Cherry-picking 🐍🍒⛏️ 87 | 88 | (Setup first! See previous section.) 89 | 90 | From the cloned CPython directory: 91 | 92 | ```console 93 | (venv) $ cherry_picker [--pr-remote REMOTE] [--upstream-remote REMOTE] [--dry-run] [--config-path CONFIG-PATH] [--abort/--continue] [--status] [--push/--no-push] [--auto-pr/--no-auto-pr] 94 | ``` 95 | 96 | ### Commit sha1 97 | 98 | The commit sha1 for cherry-picking is the squashed commit that was merged to 99 | the `main` branch. On the merged pull request, scroll to the bottom of the 100 | page. Find the event that says something like: 101 | 102 | ``` 103 | merged commit into python:main ago. 104 | ``` 105 | 106 | By following the link to ``, you will get the full commit hash. 107 | Use the full commit hash for `cherry_picker.py`. 108 | 109 | 110 | ### Options 111 | 112 | ``` 113 | --dry-run Dry Run Mode. Prints out the commands, but not executed. 114 | --pr-remote REMOTE Specify the git remote to push into. Default is 'origin'. 115 | --upstream-remote REMOTE Specify the git remote to use for upstream branches. 116 | Default is 'upstream' or 'origin' if the former doesn't exist. 117 | --status Do `git status` in cpython directory. 118 | ``` 119 | 120 | Additional options: 121 | 122 | ``` 123 | --abort Abort current cherry-pick and clean up branch 124 | --continue Continue cherry-pick, push, and clean up branch 125 | --no-push Changes won't be pushed to remote 126 | --no-auto-pr PR creation page won't be automatically opened in the web browser or 127 | if GH_AUTH is set, the PR won't be automatically opened through API. 128 | --config-path Path to config file 129 | (`.cherry_picker.toml` from project root by default) 130 | ``` 131 | 132 | Configuration file example: 133 | 134 | ```toml 135 | team = "aio-libs" 136 | repo = "aiohttp" 137 | check_sha = "f382b5ffc445e45a110734f5396728da7914aeb6" 138 | fix_commit_msg = false 139 | default_branch = "devel" 140 | require_version_in_branch_name = false 141 | draft_pr = false 142 | ``` 143 | 144 | Available config options: 145 | 146 | ``` 147 | team github organization or individual nick, 148 | e.g "aio-libs" for https://github.com/aio-libs/aiohttp 149 | ("python" by default) 150 | 151 | repo github project name, 152 | e.g "aiohttp" for https://github.com/aio-libs/aiohttp 153 | ("cpython" by default) 154 | 155 | check_sha A long hash for any commit from the repo, 156 | e.g. a sha1 hash from the very first initial commit 157 | ("7f777ed95a19224294949e1b4ce56bbffcb1fe9f" by default) 158 | 159 | fix_commit_msg Replace # with GH- in cherry-picked commit message. 160 | It is the default behavior for CPython because of external 161 | Roundup bug tracker (https://bugs.python.org) behavior: 162 | #xxxx should point on issue xxxx but GH-xxxx points 163 | on pull-request xxxx. 164 | For projects using GitHub Issues, this option can be disabled. 165 | 166 | default_branch Project's default branch name, 167 | e.g "devel" for https://github.com/ansible/ansible 168 | ("main" by default) 169 | 170 | require_version_in_branch_name Allow backporting to branches whose names don't contain 171 | something that resembles a version number 172 | (i.e. at least two dot-separated numbers). 173 | 174 | draft_pr Create PR as draft 175 | (false by default) 176 | ``` 177 | 178 | To customize the tool for used by other project: 179 | 180 | 1. Create a file called `.cherry_picker.toml` in the project's root 181 | folder (alongside with `.git` folder). 182 | 183 | 2. Add `team`, `repo`, `fix_commit_msg`, `check_sha` and 184 | `default_branch` config values as described above. 185 | 186 | 3. Use `git add .cherry_picker.toml` / `git commit` to add the config 187 | into Git. 188 | 189 | 4. Add `cherry_picker` to development dependencies or install it 190 | by `pip install cherry_picker` 191 | 192 | 5. Now everything is ready, use `cherry_picker 193 | ` for cherry-picking changes from `` into 194 | maintenance branches. 195 | Branch name should contain at least major and minor version numbers 196 | and may have some prefix or suffix. 197 | Only the first version-like substring is matched when the version 198 | is extracted from branch name. 199 | 200 | ### Demo 201 | 202 | - Installation: https://asciinema.org/a/125254 203 | 204 | - Backport: https://asciinema.org/a/125256 205 | 206 | 207 | ### Example 208 | 209 | For example, to cherry-pick `6de2b7817f-some-commit-sha1-d064` into 210 | `3.12` and `3.11`, run the following command from the cloned CPython 211 | directory: 212 | 213 | ```console 214 | (venv) $ cherry_picker 6de2b7817f-some-commit-sha1-d064 3.12 3.11 215 | ``` 216 | 217 | What this will do: 218 | 219 | ```console 220 | (venv) $ git fetch upstream 221 | 222 | (venv) $ git checkout -b backport-6de2b78-3.12 upstream/3.12 223 | (venv) $ git cherry-pick -x 6de2b7817f-some-commit-sha1-d064 224 | (venv) $ git push origin backport-6de2b78-3.12 225 | (venv) $ git checkout main 226 | (venv) $ git branch -D backport-6de2b78-3.12 227 | 228 | (venv) $ git checkout -b backport-6de2b78-3.11 upstream/3.11 229 | (venv) $ git cherry-pick -x 6de2b7817f-some-commit-sha1-d064 230 | (venv) $ git push origin backport-6de2b78-3.11 231 | (venv) $ git checkout main 232 | (venv) $ git branch -D backport-6de2b78-3.11 233 | ``` 234 | 235 | In case of merge conflicts or errors, the following message will be displayed: 236 | 237 | ``` 238 | Failed to cherry-pick 554626ada769abf82a5dabe6966afa4265acb6a6 into 2.7 :frowning_face: 239 | ... Stopping here. 240 | 241 | To continue and resolve the conflict: 242 | $ cherry_picker --status # to find out which files need attention 243 | # Fix the conflict 244 | $ cherry_picker --status # should now say 'all conflict fixed' 245 | $ cherry_picker --continue 246 | 247 | To abort the cherry-pick and cleanup: 248 | $ cherry_picker --abort 249 | ``` 250 | 251 | Passing the `--dry-run` option will cause the script to print out all the 252 | steps it would execute without actually executing any of them. For example: 253 | 254 | ```console 255 | $ cherry_picker --dry-run --pr-remote pr 1e32a1be4a1705e34011770026cb64ada2d340b5 3.12 3.11 256 | Dry run requested, listing expected command sequence 257 | fetching upstream ... 258 | dry_run: git fetch origin 259 | Now backporting '1e32a1be4a1705e34011770026cb64ada2d340b5' into '3.12' 260 | dry_run: git checkout -b backport-1e32a1b-3.12 origin/3.12 261 | dry_run: git cherry-pick -x 1e32a1be4a1705e34011770026cb64ada2d340b5 262 | dry_run: git push pr backport-1e32a1b-3.12 263 | dry_run: Create new PR: https://github.com/python/cpython/compare/3.12...ncoghlan:backport-1e32a1b-3.12?expand=1 264 | dry_run: git checkout main 265 | dry_run: git branch -D backport-1e32a1b-3.12 266 | Now backporting '1e32a1be4a1705e34011770026cb64ada2d340b5' into '3.11' 267 | dry_run: git checkout -b backport-1e32a1b-3.11 origin/3.11 268 | dry_run: git cherry-pick -x 1e32a1be4a1705e34011770026cb64ada2d340b5 269 | dry_run: git push pr backport-1e32a1b-3.11 270 | dry_run: Create new PR: https://github.com/python/cpython/compare/3.11...ncoghlan:backport-1e32a1b-3.11?expand=1 271 | dry_run: git checkout main 272 | dry_run: git branch -D backport-1e32a1b-3.11 273 | ``` 274 | 275 | ### `--pr-remote` option 276 | 277 | This will generate pull requests through a remote other than `origin` 278 | (e.g. `pr`) 279 | 280 | ### `--upstream-remote` option 281 | 282 | This will generate branches from a remote other than `upstream`/`origin` 283 | (e.g. `python`) 284 | 285 | ### `--status` option 286 | 287 | This will do `git status` for the CPython directory. 288 | 289 | ### `--abort` option 290 | 291 | Cancels the current cherry-pick and cleans up the cherry-pick branch. 292 | 293 | ### `--continue` option 294 | 295 | Continues the current cherry-pick, commits, pushes the current branch to 296 | `origin`, opens the PR page, and cleans up the branch. 297 | 298 | ### `--no-push` option 299 | 300 | Changes won't be pushed to remote. This allows you to test and make additional 301 | changes. Once you're satisfied with local changes, use `--continue` to complete 302 | the backport, or `--abort` to cancel and clean up the branch. You can also 303 | cherry-pick additional commits, by: 304 | 305 | ```console 306 | $ git cherry-pick -x 307 | ``` 308 | 309 | ### `--no-auto-pr` option 310 | 311 | PR creation page won't be automatically opened in the web browser or 312 | if GH_AUTH is set, the PR won't be automatically opened through API. 313 | This can be useful if your terminal is not capable of opening a useful web browser, 314 | or if you use cherry-picker with a different Git hosting than GitHub. 315 | 316 | ### `--config-path` option 317 | 318 | Allows to override default config file path 319 | (`/.cherry_picker.toml`) with a custom one. This allows cherry_picker 320 | to backport projects other than CPython. 321 | 322 | 323 | ## Creating pull requests 324 | 325 | When a cherry-pick was applied successfully, this script will open up a browser 326 | tab that points to the pull request creation page. 327 | 328 | The url of the pull request page looks similar to the following: 329 | 330 | ``` 331 | https://github.com/python/cpython/compare/3.12...:backport-6de2b78-3.12?expand=1 332 | ``` 333 | 334 | Press the `Create Pull Request` button. 335 | 336 | Bedevere will then remove the `needs backport to ...` label from the original 337 | pull request against `main`. 338 | 339 | 340 | ## Running tests 341 | 342 | ```console 343 | $ # Install pytest 344 | $ pip install -U pytest 345 | $ # Run tests 346 | $ pytest 347 | ``` 348 | 349 | Tests require your local version of Git to be 2.28.0+. 350 | 351 | ## Publishing to PyPI 352 | 353 | - See the [release checklist](https://github.com/python/cherry-picker/blob/main/RELEASING.md). 354 | 355 | 356 | ## Local installation 357 | 358 | In the directory where `pyproject.toml` exists: 359 | 360 | ```console 361 | $ pip install 362 | ``` 363 | 364 | ## Changelog 365 | 366 | See the [changelog](https://github.com/python/cherry-picker/blob/main/CHANGELOG.md). 367 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | - [ ] check tests pass on [GitHub Actions](https://github.com/python/cherry-picker/actions) 4 | [![GitHub Actions status](https://github.com/python/cherry-picker/actions/workflows/main.yml/badge.svg)](https://github.com/python/cherry-picker/actions/workflows/main.yml) 5 | 6 | - [ ] Update [changelog](https://github.com/python/cherry-picker/blob/main/CHANGELOG.md) 7 | 8 | - [ ] Go to the [Releases page](https://github.com/python/cherry-picker/releases) and 9 | 10 | - [ ] Click "Draft a new release" 11 | 12 | - [ ] Click "Choose a tag" 13 | 14 | - [ ] Type the next `cherry-picker-vX.Y.Z` version and select "**Create new tag: cherry-picker-vX.Y.Z** on publish" 15 | 16 | - [ ] Leave the "Release title" blank (it will be autofilled) 17 | 18 | - [ ] Click "Generate release notes" and amend as required 19 | 20 | - [ ] Click "Publish release" 21 | 22 | - [ ] Check the tagged [GitHub Actions build](https://github.com/python/cherry-picker/actions/workflows/deploy.yml) 23 | has deployed to [PyPI](https://pypi.org/project/cherry_picker/#history) 24 | 25 | - [ ] Check installation: 26 | 27 | ```bash 28 | python -m pip uninstall -y cherry_picker && python -m pip install -U cherry_picker && cherry_picker --version 29 | ``` 30 | -------------------------------------------------------------------------------- /cherry_picker/__init__.py: -------------------------------------------------------------------------------- 1 | """Backport CPython changes from main to maintenance branches.""" 2 | 3 | from __future__ import annotations 4 | 5 | from ._version import __version__ 6 | 7 | __all__ = ["__version__"] 8 | -------------------------------------------------------------------------------- /cherry_picker/__main__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .cherry_picker import cherry_pick_cli 4 | 5 | if __name__ == "__main__": 6 | cherry_pick_cli() 7 | -------------------------------------------------------------------------------- /cherry_picker/cherry_picker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import annotations 4 | 5 | import collections 6 | import enum 7 | import functools 8 | import os 9 | import re 10 | import subprocess 11 | import sys 12 | import webbrowser 13 | 14 | import click 15 | import requests 16 | import stamina 17 | from gidgethub import sansio 18 | 19 | from . import __version__ 20 | 21 | if sys.version_info >= (3, 11): 22 | import tomllib 23 | else: 24 | import tomli as tomllib 25 | 26 | CREATE_PR_URL_TEMPLATE = ( 27 | "https://api.github.com/repos/{config[team]}/{config[repo]}/pulls" 28 | ) 29 | DEFAULT_CONFIG = collections.ChainMap( 30 | { 31 | "team": "python", 32 | "repo": "cpython", 33 | "check_sha": "7f777ed95a19224294949e1b4ce56bbffcb1fe9f", 34 | "fix_commit_msg": True, 35 | "default_branch": "main", 36 | "require_version_in_branch_name": True, 37 | "draft_pr": False, 38 | } 39 | ) 40 | 41 | 42 | WORKFLOW_STATES = enum.Enum( 43 | "WORKFLOW_STATES", 44 | """ 45 | FETCHING_UPSTREAM 46 | FETCHED_UPSTREAM 47 | 48 | CHECKING_OUT_DEFAULT_BRANCH 49 | CHECKED_OUT_DEFAULT_BRANCH 50 | 51 | CHECKING_OUT_PREVIOUS_BRANCH 52 | CHECKED_OUT_PREVIOUS_BRANCH 53 | 54 | PUSHING_TO_REMOTE 55 | PUSHED_TO_REMOTE 56 | PUSHING_TO_REMOTE_FAILED 57 | 58 | PR_CREATING 59 | PR_CREATING_FAILED 60 | PR_OPENING 61 | 62 | REMOVING_BACKPORT_BRANCH 63 | REMOVING_BACKPORT_BRANCH_FAILED 64 | REMOVED_BACKPORT_BRANCH 65 | 66 | BACKPORT_STARTING 67 | BACKPORT_LOOPING 68 | BACKPORT_LOOP_START 69 | BACKPORT_LOOP_END 70 | 71 | ABORTING 72 | ABORTED 73 | ABORTING_FAILED 74 | 75 | CONTINUATION_STARTED 76 | BACKPORTING_CONTINUATION_SUCCEED 77 | CONTINUATION_FAILED 78 | 79 | BACKPORT_PAUSED 80 | 81 | UNSET 82 | """, 83 | ) 84 | 85 | 86 | class BranchCheckoutException(Exception): 87 | def __init__(self, branch_name): 88 | self.branch_name = branch_name 89 | super().__init__(f"Error checking out the branch {branch_name!r}.") 90 | 91 | 92 | class CherryPickException(Exception): 93 | pass 94 | 95 | 96 | class InvalidRepoException(Exception): 97 | pass 98 | 99 | 100 | class GitHubException(Exception): 101 | pass 102 | 103 | 104 | class CherryPicker: 105 | ALLOWED_STATES = WORKFLOW_STATES.BACKPORT_PAUSED, WORKFLOW_STATES.UNSET 106 | """The list of states expected at the start of the app.""" 107 | 108 | def __init__( 109 | self, 110 | pr_remote, 111 | commit_sha1, 112 | branches, 113 | *, 114 | upstream_remote=None, 115 | dry_run=False, 116 | push=True, 117 | prefix_commit=True, 118 | config=DEFAULT_CONFIG, 119 | chosen_config_path=None, 120 | auto_pr=True, 121 | ): 122 | self.chosen_config_path = chosen_config_path 123 | """The config reference used in the current runtime. 124 | 125 | It starts with a Git revision specifier, followed by a colon 126 | and a path relative to the repo root. 127 | """ 128 | 129 | self.config = config 130 | self.check_repo() # may raise InvalidRepoException 131 | 132 | """The runtime state loaded from the config. 133 | 134 | Used to verify that we resume the process from the valid 135 | previous state. 136 | """ 137 | 138 | if dry_run: 139 | click.echo("Dry run requested, listing expected command sequence") 140 | 141 | self.pr_remote = pr_remote 142 | self.upstream_remote = upstream_remote 143 | self.commit_sha1 = commit_sha1 144 | self.branches = branches 145 | self.dry_run = dry_run 146 | self.push = push 147 | self.auto_pr = auto_pr 148 | self.prefix_commit = prefix_commit 149 | 150 | # the cached calculated value of self.upstream property 151 | self._upstream = None 152 | 153 | # This is set to the PR number when cherry-picker successfully 154 | # creates a PR through API. 155 | self.pr_number = None 156 | 157 | def set_paused_state(self): 158 | """Save paused progress state into Git config.""" 159 | if self.chosen_config_path is not None: 160 | save_cfg_vals_to_git_cfg(config_path=self.chosen_config_path) 161 | set_state(WORKFLOW_STATES.BACKPORT_PAUSED) 162 | 163 | def remember_previous_branch(self): 164 | """Save the current branch into Git config, to be used later.""" 165 | current_branch = get_current_branch() 166 | save_cfg_vals_to_git_cfg(previous_branch=current_branch) 167 | 168 | @property 169 | def upstream(self): 170 | """Get the remote name to use for upstream branches 171 | 172 | Uses the remote passed to `--upstream-remote`. 173 | If this flag wasn't passed, it uses "upstream" if it exists or "origin" 174 | otherwise. 175 | """ 176 | # the cached calculated value of the property 177 | if self._upstream is not None: 178 | return self._upstream 179 | 180 | cmd = ["git", "remote", "get-url", "upstream"] 181 | if self.upstream_remote is not None: 182 | cmd[-1] = self.upstream_remote 183 | 184 | try: 185 | self.run_cmd(cmd, required_real_result=True) 186 | except subprocess.CalledProcessError: 187 | if self.upstream_remote is not None: 188 | raise ValueError(f"There is no remote with name {cmd[-1]!r}.") 189 | cmd[-1] = "origin" 190 | try: 191 | self.run_cmd(cmd) 192 | except subprocess.CalledProcessError: 193 | raise ValueError( 194 | "There are no remotes with name 'upstream' or 'origin'." 195 | ) 196 | 197 | self._upstream = cmd[-1] 198 | return self._upstream 199 | 200 | @property 201 | def sorted_branches(self): 202 | """Return the branches to cherry-pick to, sorted by version.""" 203 | return sorted( 204 | self.branches, key=functools.partial(compute_version_sort_key, self.config) 205 | ) 206 | 207 | @property 208 | def username(self): 209 | cmd = ["git", "config", "--get", f"remote.{self.pr_remote}.url"] 210 | result = self.run_cmd(cmd, required_real_result=True).strip() 211 | # implicit ssh URIs use : to separate host from user, others just use / 212 | username = result.replace(":", "/").rstrip("/").split("/")[-2] 213 | return username 214 | 215 | def get_cherry_pick_branch(self, maint_branch): 216 | return f"backport-{self.commit_sha1[:7]}-{maint_branch}" 217 | 218 | def get_pr_url(self, base_branch, head_branch): 219 | return ( 220 | f"https://github.com/{self.config['team']}/{self.config['repo']}" 221 | f"/compare/{base_branch}...{self.username}:{head_branch}?expand=1" 222 | ) 223 | 224 | def fetch_upstream(self): 225 | """git fetch """ 226 | set_state(WORKFLOW_STATES.FETCHING_UPSTREAM) 227 | cmd = ["git", "fetch", self.upstream, "--no-tags"] 228 | self.run_cmd(cmd) 229 | set_state(WORKFLOW_STATES.FETCHED_UPSTREAM) 230 | 231 | def run_cmd(self, cmd, required_real_result=False): 232 | assert not isinstance(cmd, str) 233 | if not required_real_result and self.dry_run: 234 | click.echo(f" dry-run: {' '.join(cmd)}") 235 | return 236 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 237 | return output.decode("utf-8") 238 | 239 | def checkout_branch(self, branch_name, *, create_branch=False): 240 | """git checkout [-b] """ 241 | if create_branch: 242 | checked_out_branch = self.get_cherry_pick_branch(branch_name) 243 | cmd = [ 244 | "git", 245 | "checkout", 246 | "-b", 247 | checked_out_branch, 248 | f"{self.upstream}/{branch_name}", 249 | ] 250 | else: 251 | checked_out_branch = branch_name 252 | cmd = ["git", "checkout", branch_name] 253 | try: 254 | self.run_cmd(cmd) 255 | except subprocess.CalledProcessError as err: 256 | click.echo(f"Error checking out the branch {checked_out_branch!r}.") 257 | click.echo(err.output) 258 | raise BranchCheckoutException(checked_out_branch) 259 | if create_branch: 260 | self.unset_upstream(checked_out_branch) 261 | 262 | def get_commit_message(self, commit_sha): 263 | """ 264 | Return the commit message for the current commit hash, 265 | replace # with GH- 266 | """ 267 | cmd = ["git", "show", "-s", "--format=%B", commit_sha] 268 | try: 269 | message = self.run_cmd(cmd, required_real_result=True).strip() 270 | except subprocess.CalledProcessError as err: 271 | click.echo(f"Error getting commit message for {commit_sha}") 272 | click.echo(err.output) 273 | raise CherryPickException(f"Error getting commit message for {commit_sha}") 274 | if self.config["fix_commit_msg"]: 275 | # Only replace "#" with "GH-" with the following conditions: 276 | # * "#" is separated from the previous word 277 | # * "#" is followed by at least 5-digit number that 278 | # does not start with 0 279 | # * the number is separated from the following word 280 | return re.sub(r"\B#(?=[1-9][0-9]{4,}\b)", "GH-", message) 281 | else: 282 | return message 283 | 284 | def checkout_default_branch(self): 285 | """git checkout default branch""" 286 | set_state(WORKFLOW_STATES.CHECKING_OUT_DEFAULT_BRANCH) 287 | 288 | self.checkout_branch(self.config["default_branch"]) 289 | 290 | set_state(WORKFLOW_STATES.CHECKED_OUT_DEFAULT_BRANCH) 291 | 292 | def checkout_previous_branch(self): 293 | """git checkout previous branch""" 294 | set_state(WORKFLOW_STATES.CHECKING_OUT_PREVIOUS_BRANCH) 295 | 296 | previous_branch = load_val_from_git_cfg("previous_branch") 297 | if previous_branch is None: 298 | self.checkout_default_branch() 299 | return 300 | 301 | self.checkout_branch(previous_branch) 302 | 303 | set_state(WORKFLOW_STATES.CHECKED_OUT_PREVIOUS_BRANCH) 304 | 305 | def status(self): 306 | """ 307 | git status 308 | :return: 309 | """ 310 | cmd = ["git", "status"] 311 | return self.run_cmd(cmd) 312 | 313 | def cherry_pick(self): 314 | """git cherry-pick -x """ 315 | cmd = ["git", "cherry-pick", "-x", self.commit_sha1] 316 | try: 317 | click.echo(self.run_cmd(cmd)) 318 | except subprocess.CalledProcessError as err: 319 | click.echo(f"Error cherry-pick {self.commit_sha1}.") 320 | click.echo(err.output) 321 | raise CherryPickException(f"Error cherry-pick {self.commit_sha1}.") 322 | 323 | def get_exit_message(self, branch): 324 | return f""" 325 | Failed to cherry-pick {self.commit_sha1} into {branch} \u2639 326 | ... Stopping here. 327 | 328 | To continue and resolve the conflict: 329 | $ cherry_picker --status # to find out which files need attention 330 | # Fix the conflict 331 | $ cherry_picker --status # should now say 'all conflict fixed' 332 | $ cherry_picker --continue 333 | 334 | To abort the cherry-pick and cleanup: 335 | $ cherry_picker --abort 336 | """ 337 | 338 | def get_updated_commit_message(self, cherry_pick_branch): 339 | """ 340 | Get updated commit message for the cherry-picked commit. 341 | """ 342 | # Get the original commit message and prefix it with the branch name 343 | # if that's enabled. 344 | updated_commit_message = self.get_commit_message(self.commit_sha1) 345 | if self.prefix_commit: 346 | updated_commit_message = remove_commit_prefix(updated_commit_message) 347 | base_branch = get_base_branch(cherry_pick_branch, config=self.config) 348 | updated_commit_message = f"[{base_branch}] {updated_commit_message}" 349 | 350 | # Add '(cherry picked from commit ...)' to the message 351 | # and add new Co-authored-by trailer if necessary. 352 | cherry_pick_information = f"(cherry picked from commit {self.commit_sha1})\n:" 353 | # Here, we're inserting new Co-authored-by trailer and we *somewhat* 354 | # abuse interpret-trailers by also adding cherry_pick_information which 355 | # is not an actual trailer. 356 | # `--where start` makes it so we insert new trailers *before* the existing 357 | # trailers so cherry-pick information gets added before any of the trailers 358 | # which prevents us from breaking the trailers. 359 | cmd = [ 360 | "git", 361 | "interpret-trailers", 362 | "--where", 363 | "start", 364 | "--trailer", 365 | f"Co-authored-by: {get_author_info_from_short_sha(self.commit_sha1)}", 366 | "--trailer", 367 | cherry_pick_information, 368 | ] 369 | output = subprocess.check_output(cmd, input=updated_commit_message.encode()) 370 | # Replace the right most-occurence of the "cherry picked from commit" string. 371 | # 372 | # This needs to be done because `git interpret-trailers` required us to add `:` 373 | # to `cherry_pick_information` when we don't actually want it. 374 | before, after = ( 375 | output.strip().decode().rsplit(f"\n{cherry_pick_information}", 1) 376 | ) 377 | if not before.endswith("\n"): 378 | # ensure that we still have a newline between cherry pick information 379 | # and commit headline 380 | cherry_pick_information = f"\n{cherry_pick_information}" 381 | updated_commit_message = cherry_pick_information[:-1].join((before, after)) 382 | 383 | return updated_commit_message 384 | 385 | def amend_commit_message(self, cherry_pick_branch): 386 | """Prefix the commit message with (X.Y)""" 387 | 388 | updated_commit_message = self.get_updated_commit_message(cherry_pick_branch) 389 | if self.dry_run: 390 | click.echo(f" dry-run: git commit --amend -m '{updated_commit_message}'") 391 | else: 392 | cmd = ["git", "commit", "--amend", "-m", updated_commit_message] 393 | try: 394 | self.run_cmd(cmd) 395 | except subprocess.CalledProcessError as cpe: 396 | click.echo("Failed to amend the commit message \u2639") 397 | click.echo(cpe.output) 398 | return updated_commit_message 399 | 400 | def pause_after_committing(self, cherry_pick_branch): 401 | click.echo( 402 | f""" 403 | Finished cherry-pick {self.commit_sha1} into {cherry_pick_branch} \U0001F600 404 | --no-push option used. 405 | ... Stopping here. 406 | To continue and push the changes: 407 | $ cherry_picker --continue 408 | 409 | To abort the cherry-pick and cleanup: 410 | $ cherry_picker --abort 411 | """ 412 | ) 413 | self.set_paused_state() 414 | 415 | def push_to_remote(self, base_branch, head_branch, commit_message=""): 416 | """git push """ 417 | set_state(WORKFLOW_STATES.PUSHING_TO_REMOTE) 418 | 419 | cmd = ["git", "push"] 420 | if head_branch.startswith("backport-"): 421 | # Overwrite potential stale backport branches with extreme prejudice. 422 | cmd.append("--force-with-lease") 423 | cmd.append(self.pr_remote) 424 | if not self.is_mirror(): 425 | cmd.append(f"{head_branch}:{head_branch}") 426 | try: 427 | self.run_cmd(cmd) 428 | set_state(WORKFLOW_STATES.PUSHED_TO_REMOTE) 429 | except subprocess.CalledProcessError as cpe: 430 | click.echo(f"Failed to push to {self.pr_remote} \u2639") 431 | click.echo(cpe.output) 432 | set_state(WORKFLOW_STATES.PUSHING_TO_REMOTE_FAILED) 433 | else: 434 | if not self.auto_pr: 435 | return 436 | gh_auth = os.getenv("GH_AUTH") 437 | if gh_auth: 438 | set_state(WORKFLOW_STATES.PR_CREATING) 439 | try: 440 | self.create_gh_pr( 441 | base_branch, 442 | head_branch, 443 | commit_message=commit_message, 444 | gh_auth=gh_auth, 445 | ) 446 | except GitHubException: 447 | set_state(WORKFLOW_STATES.PR_CREATING_FAILED) 448 | raise 449 | else: 450 | set_state(WORKFLOW_STATES.PR_OPENING) 451 | self.open_pr(self.get_pr_url(base_branch, head_branch)) 452 | 453 | @stamina.retry(on=GitHubException, timeout=120) 454 | def create_gh_pr(self, base_branch, head_branch, *, commit_message, gh_auth): 455 | """ 456 | Create PR in GitHub 457 | """ 458 | request_headers = sansio.create_headers(self.username, oauth_token=gh_auth) 459 | title, body = normalize_commit_message(commit_message) 460 | if not self.prefix_commit: 461 | title = remove_commit_prefix(title) 462 | title = f"[{base_branch}] {title}" 463 | data = { 464 | "title": title, 465 | "body": body, 466 | "head": f"{self.username}:{head_branch}", 467 | "base": base_branch, 468 | "maintainer_can_modify": True, 469 | "draft": self.config["draft_pr"], 470 | } 471 | url = CREATE_PR_URL_TEMPLATE.format(config=self.config) 472 | try: 473 | response = requests.post( 474 | url, headers=request_headers, json=data, timeout=30 475 | ) 476 | except requests.exceptions.RequestException as req_exc: 477 | raise GitHubException(f"Creating PR on GitHub failed: {req_exc}") 478 | else: 479 | sc = response.status_code 480 | txt = response.text 481 | if sc != requests.codes.created: 482 | raise GitHubException( 483 | f"Unexpected response ({sc}) when creating PR on GitHub: {txt}" 484 | ) 485 | response_data = response.json() 486 | click.echo(f"Backport PR created at {response_data['html_url']}") 487 | self.pr_number = response_data["number"] 488 | 489 | def open_pr(self, url): 490 | """ 491 | open url in the web browser 492 | """ 493 | if self.dry_run: 494 | click.echo(f" dry-run: Create new PR: {url}") 495 | else: 496 | click.echo("Backport PR URL:") 497 | click.echo(url) 498 | webbrowser.open_new_tab(url) 499 | 500 | def delete_branch(self, branch): 501 | cmd = ["git", "branch", "-D", branch] 502 | return self.run_cmd(cmd) 503 | 504 | def cleanup_branch(self, branch): 505 | """Remove the temporary backport branch. 506 | 507 | Switch to the default branch before that. 508 | """ 509 | set_state(WORKFLOW_STATES.REMOVING_BACKPORT_BRANCH) 510 | try: 511 | self.checkout_previous_branch() 512 | except BranchCheckoutException: 513 | click.echo(f"branch {branch} NOT deleted.") 514 | set_state(WORKFLOW_STATES.REMOVING_BACKPORT_BRANCH_FAILED) 515 | return 516 | try: 517 | self.delete_branch(branch) 518 | except subprocess.CalledProcessError: 519 | click.echo(f"branch {branch} NOT deleted.") 520 | set_state(WORKFLOW_STATES.REMOVING_BACKPORT_BRANCH_FAILED) 521 | else: 522 | click.echo(f"branch {branch} has been deleted.") 523 | set_state(WORKFLOW_STATES.REMOVED_BACKPORT_BRANCH) 524 | 525 | def unset_upstream(self, branch): 526 | cmd = ["git", "branch", "--unset-upstream", branch] 527 | try: 528 | return self.run_cmd(cmd) 529 | except subprocess.CalledProcessError as cpe: 530 | click.echo(cpe.output) 531 | 532 | def backport(self): 533 | if not self.branches: 534 | raise click.UsageError("At least one branch must be specified.") 535 | set_state(WORKFLOW_STATES.BACKPORT_STARTING) 536 | self.fetch_upstream() 537 | self.remember_previous_branch() 538 | 539 | set_state(WORKFLOW_STATES.BACKPORT_LOOPING) 540 | for maint_branch in self.sorted_branches: 541 | set_state(WORKFLOW_STATES.BACKPORT_LOOP_START) 542 | click.echo(f"Now backporting '{self.commit_sha1}' into '{maint_branch}'") 543 | 544 | cherry_pick_branch = self.get_cherry_pick_branch(maint_branch) 545 | try: 546 | self.checkout_branch(maint_branch, create_branch=True) 547 | except BranchCheckoutException: 548 | self.checkout_default_branch() 549 | reset_stored_config_ref() 550 | reset_state() 551 | raise 552 | commit_message = "" 553 | try: 554 | self.cherry_pick() 555 | commit_message = self.amend_commit_message(cherry_pick_branch) 556 | except subprocess.CalledProcessError as cpe: 557 | click.echo(cpe.output) 558 | click.echo(self.get_exit_message(maint_branch)) 559 | except CherryPickException: 560 | click.echo(self.get_exit_message(maint_branch)) 561 | self.set_paused_state() 562 | raise 563 | else: 564 | if self.push: 565 | try: 566 | self.push_to_remote( 567 | maint_branch, cherry_pick_branch, commit_message 568 | ) 569 | except GitHubException: 570 | click.echo(self.get_exit_message(maint_branch)) 571 | self.set_paused_state() 572 | raise 573 | if not self.is_mirror(): 574 | self.cleanup_branch(cherry_pick_branch) 575 | else: 576 | self.pause_after_committing(cherry_pick_branch) 577 | return # to preserve the correct state 578 | set_state(WORKFLOW_STATES.BACKPORT_LOOP_END) 579 | reset_stored_previous_branch() 580 | reset_state() 581 | 582 | def abort_cherry_pick(self): 583 | """ 584 | run `git cherry-pick --abort` and then clean up the branch 585 | """ 586 | state = self.get_state_and_verify() 587 | if state != WORKFLOW_STATES.BACKPORT_PAUSED: 588 | raise ValueError( 589 | f"One can only abort a paused process. " 590 | f"Current state: {state}. " 591 | f"Expected state: {WORKFLOW_STATES.BACKPORT_PAUSED}" 592 | ) 593 | 594 | try: 595 | validate_sha("CHERRY_PICK_HEAD") 596 | except ValueError: 597 | pass 598 | else: 599 | cmd = ["git", "cherry-pick", "--abort"] 600 | try: 601 | set_state(WORKFLOW_STATES.ABORTING) 602 | click.echo(self.run_cmd(cmd)) 603 | set_state(WORKFLOW_STATES.ABORTED) 604 | except subprocess.CalledProcessError as cpe: 605 | click.echo(cpe.output) 606 | set_state(WORKFLOW_STATES.ABORTING_FAILED) 607 | # only delete backport branch created by cherry_picker.py 608 | if get_current_branch().startswith("backport-"): 609 | self.cleanup_branch(get_current_branch()) 610 | 611 | reset_stored_previous_branch() 612 | reset_stored_config_ref() 613 | reset_state() 614 | 615 | def continue_cherry_pick(self): 616 | """ 617 | git push origin 618 | open the PR 619 | clean up branch 620 | """ 621 | state = self.get_state_and_verify() 622 | if state != WORKFLOW_STATES.BACKPORT_PAUSED: 623 | raise ValueError( 624 | "One can only continue a paused process. " 625 | f"Current state: {state}. " 626 | f"Expected state: {WORKFLOW_STATES.BACKPORT_PAUSED}" 627 | ) 628 | 629 | cherry_pick_branch = get_current_branch() 630 | if cherry_pick_branch.startswith("backport-"): 631 | set_state(WORKFLOW_STATES.CONTINUATION_STARTED) 632 | # amend the commit message, prefix with [X.Y] 633 | base = get_base_branch(cherry_pick_branch, config=self.config) 634 | short_sha = cherry_pick_branch[ 635 | cherry_pick_branch.index("-") + 1 : cherry_pick_branch.index(base) - 1 636 | ] 637 | self.commit_sha1 = get_full_sha_from_short(short_sha) 638 | 639 | commits = get_commits_from_backport_branch(base) 640 | if len(commits) == 1: 641 | commit_message = self.amend_commit_message(cherry_pick_branch) 642 | else: 643 | commit_message = self.get_updated_commit_message(cherry_pick_branch) 644 | if self.dry_run: 645 | click.echo( 646 | f" dry-run: git commit -a -m '{commit_message}' --allow-empty" 647 | ) 648 | else: 649 | cmd = [ 650 | "git", 651 | "commit", 652 | "-a", 653 | "-m", 654 | commit_message, 655 | "--allow-empty", 656 | ] 657 | self.run_cmd(cmd) 658 | 659 | if self.push: 660 | self.push_to_remote(base, cherry_pick_branch) 661 | 662 | if not self.is_mirror(): 663 | self.cleanup_branch(cherry_pick_branch) 664 | 665 | click.echo("\nBackport PR:\n") 666 | click.echo(commit_message) 667 | set_state(WORKFLOW_STATES.BACKPORTING_CONTINUATION_SUCCEED) 668 | else: 669 | self.pause_after_committing(cherry_pick_branch) 670 | return # to preserve the correct state 671 | 672 | else: 673 | click.echo( 674 | f"Current branch ({cherry_pick_branch}) is not a backport branch. " 675 | "Will not continue. \U0001F61B" 676 | ) 677 | set_state(WORKFLOW_STATES.CONTINUATION_FAILED) 678 | 679 | reset_stored_previous_branch() 680 | reset_stored_config_ref() 681 | reset_state() 682 | 683 | def check_repo(self): 684 | """ 685 | Check that the repository is for the project we're configured to operate on. 686 | 687 | This function performs the check by making sure that the sha specified in the 688 | config is present in the repository that we're operating on. 689 | """ 690 | try: 691 | validate_sha(self.config["check_sha"]) 692 | self.get_state_and_verify() 693 | except ValueError as ve: 694 | raise InvalidRepoException(ve.args[0]) 695 | 696 | def get_state_and_verify(self): 697 | """Return the run progress state stored in the Git config. 698 | 699 | Raises ValueError if the retrieved state is not of a form that 700 | cherry_picker would have stored in the config. 701 | """ 702 | try: 703 | state = get_state() 704 | except KeyError as ke: 705 | 706 | class state: 707 | name = str(ke.args[0]) 708 | 709 | if state not in self.ALLOWED_STATES: 710 | raise ValueError( 711 | f"Run state cherry-picker.state={state.name} in Git config " 712 | "is not known.\nPerhaps it has been set by a newer " 713 | "version of cherry-picker. Try upgrading.\n" 714 | "Valid states are: " 715 | f'{", ".join(s.name for s in self.ALLOWED_STATES)}. ' 716 | "If this looks suspicious, raise an issue at " 717 | "https://github.com/python/cherry-picker/issues/new.\n" 718 | "As the last resort you can reset the runtime state " 719 | "stored in Git config using the following command: " 720 | "`git config --local --remove-section cherry-picker`" 721 | ) 722 | return state 723 | 724 | def is_mirror(self) -> bool: 725 | """Return True if the current repository was created with --mirror.""" 726 | 727 | cmd = ["git", "config", "--local", "--get", "remote.origin.mirror"] 728 | try: 729 | out = self.run_cmd(cmd, required_real_result=True) 730 | except subprocess.CalledProcessError: 731 | return False 732 | return out.startswith("true") 733 | 734 | 735 | CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} 736 | 737 | 738 | @click.command(context_settings=CONTEXT_SETTINGS) 739 | @click.version_option(version=__version__) 740 | @click.option( 741 | "--dry-run", is_flag=True, help="Prints out the commands, but not executed." 742 | ) 743 | @click.option( 744 | "--pr-remote", 745 | "pr_remote", 746 | metavar="REMOTE", 747 | help="git remote to use for PR branches", 748 | default="origin", 749 | ) 750 | @click.option( 751 | "--upstream-remote", 752 | "upstream_remote", 753 | metavar="REMOTE", 754 | help="git remote to use for upstream branches", 755 | default=None, 756 | ) 757 | @click.option( 758 | "--abort", 759 | "abort", 760 | flag_value=True, 761 | default=None, 762 | help="Abort current cherry-pick and clean up branch", 763 | ) 764 | @click.option( 765 | "--continue", 766 | "abort", 767 | flag_value=False, 768 | default=None, 769 | help="Continue cherry-pick, push, and clean up branch", 770 | ) 771 | @click.option( 772 | "--status", 773 | "status", 774 | flag_value=True, 775 | default=None, 776 | help="Get the status of cherry-pick", 777 | ) 778 | @click.option( 779 | "--push/--no-push", 780 | "push", 781 | is_flag=True, 782 | default=True, 783 | help="Changes won't be pushed to remote", 784 | ) 785 | @click.option( 786 | "--auto-pr/--no-auto-pr", 787 | "auto_pr", 788 | is_flag=True, 789 | default=True, 790 | help=( 791 | "If auto PR is enabled, cherry-picker will automatically open a PR" 792 | " through API if GH_AUTH env var is set, or automatically open the PR" 793 | " creation page in the web browser otherwise." 794 | ), 795 | ) 796 | @click.option( 797 | "--config-path", 798 | "config_path", 799 | metavar="CONFIG-PATH", 800 | help=( 801 | "Path to config file, .cherry_picker.toml " 802 | "from project root by default. You can prepend " 803 | "a colon-separated Git 'commitish' reference." 804 | ), 805 | default=None, 806 | ) 807 | @click.argument("commit_sha1", nargs=1, default="") 808 | @click.argument("branches", nargs=-1) 809 | @click.pass_context 810 | def cherry_pick_cli( 811 | ctx, 812 | dry_run, 813 | pr_remote, 814 | upstream_remote, 815 | abort, 816 | status, 817 | push, 818 | auto_pr, 819 | config_path, 820 | commit_sha1, 821 | branches, 822 | ): 823 | """cherry-pick COMMIT_SHA1 into target BRANCHES.""" 824 | 825 | click.echo("\U0001F40D \U0001F352 \u26CF") 826 | 827 | try: 828 | chosen_config_path, config = load_config(config_path) 829 | except ValueError as exc: 830 | click.echo("You're not inside a Git tree right now! \U0001F645", err=True) 831 | click.echo(exc, err=True) 832 | sys.exit(-1) 833 | try: 834 | cherry_picker = CherryPicker( 835 | pr_remote, 836 | commit_sha1, 837 | branches, 838 | upstream_remote=upstream_remote, 839 | dry_run=dry_run, 840 | push=push, 841 | auto_pr=auto_pr, 842 | config=config, 843 | chosen_config_path=chosen_config_path, 844 | ) 845 | except InvalidRepoException as ire: 846 | click.echo(ire.args[0], err=True) 847 | sys.exit(-1) 848 | except ValueError as exc: 849 | ctx.fail(exc) 850 | 851 | if abort is not None: 852 | if abort: 853 | cherry_picker.abort_cherry_pick() 854 | else: 855 | cherry_picker.continue_cherry_pick() 856 | 857 | elif status: 858 | click.echo(cherry_picker.status()) 859 | else: 860 | try: 861 | cherry_picker.backport() 862 | except BranchCheckoutException: 863 | sys.exit(-1) 864 | except CherryPickException: 865 | sys.exit(-1) 866 | 867 | 868 | def get_base_branch(cherry_pick_branch, *, config): 869 | """ 870 | return '2.7' from 'backport-sha-2.7' 871 | 872 | raises ValueError if the specified branch name is not of a form that 873 | cherry_picker would have created 874 | """ 875 | prefix, sha, base_branch = cherry_pick_branch.split("-", 2) 876 | 877 | if prefix != "backport": 878 | raise ValueError( 879 | 'branch name is not prefixed with "backport-". ' 880 | "Is this a cherry_picker branch?" 881 | ) 882 | 883 | if not re.match("[0-9a-f]{7,40}", sha): 884 | raise ValueError(f"branch name has an invalid sha: {sha}") 885 | 886 | # Validate that the sha refers to a valid commit within the repo 887 | # Throws a ValueError if the sha is not present in the repo 888 | validate_sha(sha) 889 | 890 | # Subject the parsed base_branch to the same tests as when we generated it 891 | # This throws a ValueError if the base_branch doesn't meet our requirements 892 | compute_version_sort_key(config, base_branch) 893 | 894 | return base_branch 895 | 896 | 897 | def validate_sha(sha): 898 | """ 899 | Validate that a hexdigest sha is a valid commit in the repo 900 | 901 | raises ValueError if the sha does not reference a commit within the repo 902 | """ 903 | cmd = ["git", "log", "--max-count=1", "-r", sha] 904 | try: 905 | subprocess.check_output(cmd, stderr=subprocess.STDOUT) 906 | except subprocess.SubprocessError: 907 | raise ValueError( 908 | f"The sha listed in the branch name, {sha}, " 909 | "is not present in the repository" 910 | ) 911 | 912 | 913 | def compute_version_sort_key(config, branch): 914 | """ 915 | Get sort key based on version information from the given git branch name. 916 | 917 | This function can be used as a sort key in list.sort()/sorted() provided that 918 | you additionally pass config as a first argument by e.g. wrapping it with 919 | functools.partial(). 920 | 921 | Branches with version information come first and are sorted from latest 922 | to oldest version. 923 | Branches without version information come second and are sorted alphabetically. 924 | """ 925 | m = re.search(r"\d+(?:\.\d+)+", branch) 926 | if m: 927 | raw_version = m[0].split(".") 928 | # Use 0 to sort version numbers *before* regular branch names 929 | return (0, *(-int(x) for x in raw_version)) 930 | 931 | if not branch: 932 | raise ValueError("Branch name is an empty string.") 933 | if config["require_version_in_branch_name"]: 934 | raise ValueError(f"Branch {branch} seems to not have a version in its name.") 935 | 936 | # Use 1 to sort regular branch names *after* version numbers 937 | return (1, branch) 938 | 939 | 940 | def get_current_branch(): 941 | """ 942 | Return the current branch 943 | """ 944 | cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"] 945 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 946 | return output.strip().decode("utf-8") 947 | 948 | 949 | def get_full_sha_from_short(short_sha): 950 | cmd = ["git", "log", "-1", "--format=%H", short_sha] 951 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 952 | full_sha = output.strip().decode("utf-8") 953 | return full_sha 954 | 955 | 956 | def get_author_info_from_short_sha(short_sha): 957 | cmd = ["git", "log", "-1", "--format=%aN <%ae>", short_sha] 958 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 959 | author = output.strip().decode("utf-8") 960 | return author 961 | 962 | 963 | def get_commits_from_backport_branch(cherry_pick_branch): 964 | cmd = ["git", "log", "--format=%H", f"{cherry_pick_branch}.."] 965 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 966 | commits = output.strip().decode("utf-8").splitlines() 967 | return commits 968 | 969 | 970 | def normalize_commit_message(commit_message): 971 | """ 972 | Return a tuple of title and body from the commit message 973 | """ 974 | title, _, body = commit_message.partition("\n") 975 | return title, body.lstrip("\n") 976 | 977 | 978 | def remove_commit_prefix(commit_message): 979 | """ 980 | Remove prefix "[X.Y] " from the commit message 981 | """ 982 | while True: 983 | m = re.match(r"\[\d+(?:\.\d+)+\] *", commit_message) 984 | if not m: 985 | return commit_message 986 | commit_message = commit_message[m.end() :] 987 | 988 | 989 | def is_git_repo(): 990 | """Check whether the current folder is a Git repo.""" 991 | cmd = "git", "rev-parse", "--git-dir" 992 | try: 993 | subprocess.run(cmd, stdout=subprocess.DEVNULL, check=True) 994 | return True 995 | except subprocess.CalledProcessError: 996 | return False 997 | 998 | 999 | def find_config(revision): 1000 | """Locate and return the default config for current revision.""" 1001 | if not is_git_repo(): 1002 | return None 1003 | 1004 | cfg_path = f"{revision}:.cherry_picker.toml" 1005 | cmd = "git", "cat-file", "-t", cfg_path 1006 | 1007 | try: 1008 | output = subprocess.check_output(cmd, stderr=subprocess.STDOUT) 1009 | path_type = output.strip().decode("utf-8") 1010 | return cfg_path if path_type == "blob" else None 1011 | except subprocess.CalledProcessError: 1012 | return None 1013 | 1014 | 1015 | def load_config(path=None): 1016 | """Choose and return the config path and it's contents as dict.""" 1017 | # NOTE: Initially I wanted to inherit Path to encapsulate Git access 1018 | # there but there's no easy way to subclass pathlib.Path :( 1019 | head_sha = get_sha1_from("HEAD") 1020 | revision = head_sha 1021 | saved_config_path = load_val_from_git_cfg("config_path") 1022 | if not path and saved_config_path is not None: 1023 | path = saved_config_path 1024 | 1025 | if path is None: 1026 | path = find_config(revision=revision) 1027 | else: 1028 | if ":" not in path: 1029 | path = f"{head_sha}:{path}" 1030 | 1031 | revision, _col, _path = path.partition(":") 1032 | if not revision: 1033 | revision = head_sha 1034 | 1035 | config = DEFAULT_CONFIG 1036 | 1037 | if path is not None: 1038 | config_text = from_git_rev_read(path) 1039 | d = tomllib.loads(config_text) 1040 | config = config.new_child(d) 1041 | 1042 | return path, config 1043 | 1044 | 1045 | def get_sha1_from(commitish): 1046 | """Turn 'commitish' into its sha1 hash.""" 1047 | cmd = ["git", "rev-parse", commitish] 1048 | try: 1049 | return ( 1050 | subprocess.check_output(cmd, stderr=subprocess.PIPE).strip().decode("utf-8") 1051 | ) 1052 | except subprocess.CalledProcessError as exc: 1053 | raise ValueError(exc.stderr.strip().decode("utf-8")) 1054 | 1055 | 1056 | def reset_stored_config_ref(): 1057 | """Remove the config path option from Git config.""" 1058 | try: 1059 | wipe_cfg_vals_from_git_cfg("config_path") 1060 | except subprocess.CalledProcessError: 1061 | """Config file pointer is not stored in Git config.""" 1062 | 1063 | 1064 | def reset_stored_previous_branch(): 1065 | """Remove the previous branch information from Git config.""" 1066 | wipe_cfg_vals_from_git_cfg("previous_branch") 1067 | 1068 | 1069 | def reset_state(): 1070 | """Remove the progress state from Git config.""" 1071 | wipe_cfg_vals_from_git_cfg("state") 1072 | 1073 | 1074 | def set_state(state): 1075 | """Save progress state into Git config.""" 1076 | save_cfg_vals_to_git_cfg(state=state.name) 1077 | 1078 | 1079 | def get_state(): 1080 | """Retrieve the progress state from Git config.""" 1081 | return get_state_from_string(load_val_from_git_cfg("state") or "UNSET") 1082 | 1083 | 1084 | def save_cfg_vals_to_git_cfg(**cfg_map): 1085 | """Save a set of options into Git config.""" 1086 | for cfg_key_suffix, cfg_val in cfg_map.items(): 1087 | cfg_key = f'cherry-picker.{cfg_key_suffix.replace("_", "-")}' 1088 | cmd = "git", "config", "--local", cfg_key, cfg_val 1089 | subprocess.check_call(cmd, stderr=subprocess.STDOUT) 1090 | 1091 | 1092 | def wipe_cfg_vals_from_git_cfg(*cfg_opts): 1093 | """Remove a set of options from Git config.""" 1094 | for cfg_key_suffix in cfg_opts: 1095 | cfg_key = f'cherry-picker.{cfg_key_suffix.replace("_", "-")}' 1096 | cmd = "git", "config", "--local", "--unset-all", cfg_key 1097 | subprocess.check_call(cmd, stderr=subprocess.STDOUT) 1098 | 1099 | 1100 | def load_val_from_git_cfg(cfg_key_suffix): 1101 | """Retrieve one option from Git config.""" 1102 | cfg_key = f'cherry-picker.{cfg_key_suffix.replace("_", "-")}' 1103 | cmd = "git", "config", "--local", "--get", cfg_key 1104 | try: 1105 | return ( 1106 | subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 1107 | .strip() 1108 | .decode("utf-8") 1109 | ) 1110 | except subprocess.CalledProcessError: 1111 | return None 1112 | 1113 | 1114 | def from_git_rev_read(path): 1115 | """Retrieve given file path contents of certain Git revision.""" 1116 | if ":" not in path: 1117 | raise ValueError("Path identifier must start with a revision hash.") 1118 | 1119 | cmd = "git", "show", "-t", path 1120 | try: 1121 | return subprocess.check_output(cmd).rstrip().decode("utf-8") 1122 | except subprocess.CalledProcessError: 1123 | raise ValueError 1124 | 1125 | 1126 | def get_state_from_string(state_str): 1127 | return WORKFLOW_STATES.__members__[state_str] 1128 | 1129 | 1130 | if __name__ == "__main__": 1131 | cherry_pick_cli() 1132 | -------------------------------------------------------------------------------- /cherry_picker/test_cherry_picker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import pathlib 5 | import re 6 | import subprocess 7 | import warnings 8 | from collections import ChainMap 9 | from unittest import mock 10 | from unittest.mock import MagicMock 11 | 12 | import click 13 | import pytest 14 | 15 | from .cherry_picker import ( 16 | DEFAULT_CONFIG, 17 | WORKFLOW_STATES, 18 | BranchCheckoutException, 19 | CherryPicker, 20 | CherryPickException, 21 | InvalidRepoException, 22 | find_config, 23 | from_git_rev_read, 24 | get_author_info_from_short_sha, 25 | get_base_branch, 26 | get_commits_from_backport_branch, 27 | get_current_branch, 28 | get_full_sha_from_short, 29 | get_sha1_from, 30 | get_state, 31 | load_config, 32 | load_val_from_git_cfg, 33 | normalize_commit_message, 34 | remove_commit_prefix, 35 | reset_state, 36 | reset_stored_config_ref, 37 | set_state, 38 | validate_sha, 39 | ) 40 | 41 | 42 | @pytest.fixture 43 | def config(): 44 | check_sha = "0694156a5f80fd1647ed997470d3d03ed87f9d9f" 45 | return ChainMap(DEFAULT_CONFIG).new_child({"check_sha": check_sha}) 46 | 47 | 48 | @pytest.fixture 49 | def cd(): 50 | cwd = os.getcwd() 51 | 52 | def changedir(d): 53 | os.chdir(d) 54 | 55 | yield changedir 56 | 57 | # restore CWD back 58 | os.chdir(cwd) 59 | 60 | 61 | @pytest.fixture 62 | def git_init(): 63 | git_init_cmd = "git", "init", "--initial-branch=main", "." 64 | return lambda: subprocess.run(git_init_cmd, check=True) 65 | 66 | 67 | @pytest.fixture 68 | def git_remote(): 69 | git_remote_cmd = "git", "remote" 70 | return lambda *extra_args: (subprocess.run(git_remote_cmd + extra_args, check=True)) 71 | 72 | 73 | @pytest.fixture 74 | def git_add(): 75 | git_add_cmd = "git", "add" 76 | return lambda *extra_args: (subprocess.run(git_add_cmd + extra_args, check=True)) 77 | 78 | 79 | @pytest.fixture 80 | def git_checkout(): 81 | git_checkout_cmd = "git", "checkout" 82 | return lambda *extra_args: ( 83 | subprocess.run(git_checkout_cmd + extra_args, check=True) 84 | ) 85 | 86 | 87 | @pytest.fixture 88 | def git_branch(): 89 | git_branch_cmd = "git", "branch" 90 | return lambda *extra_args: (subprocess.run(git_branch_cmd + extra_args, check=True)) 91 | 92 | 93 | @pytest.fixture 94 | def git_commit(): 95 | git_commit_cmd = "git", "commit", "-m" 96 | return lambda msg, *extra_args: ( 97 | subprocess.run(git_commit_cmd + (msg,) + extra_args, check=True) 98 | ) 99 | 100 | 101 | @pytest.fixture 102 | def git_worktree(): 103 | git_worktree_cmd = "git", "worktree" 104 | return lambda *extra_args: ( 105 | subprocess.run(git_worktree_cmd + extra_args, check=True) 106 | ) 107 | 108 | 109 | @pytest.fixture 110 | def git_cherry_pick(): 111 | git_cherry_pick_cmd = "git", "cherry-pick" 112 | return lambda *extra_args: ( 113 | subprocess.run(git_cherry_pick_cmd + extra_args, check=True) 114 | ) 115 | 116 | 117 | @pytest.fixture 118 | def git_reset(): 119 | git_reset_cmd = "git", "reset" 120 | return lambda *extra_args: (subprocess.run(git_reset_cmd + extra_args, check=True)) 121 | 122 | 123 | @pytest.fixture 124 | def git_config(): 125 | git_config_cmd = "git", "config" 126 | return lambda *extra_args: (subprocess.run(git_config_cmd + extra_args, check=True)) 127 | 128 | 129 | @pytest.fixture 130 | def tmp_git_repo_dir(tmpdir, cd, git_init, git_commit, git_config): 131 | repo_dir = tmpdir.mkdir("tmp-git-repo") 132 | cd(repo_dir) 133 | try: 134 | git_init() 135 | except subprocess.CalledProcessError: 136 | version = subprocess.run(("git", "--version"), capture_output=True) 137 | # the output looks like "git version 2.34.1" 138 | v = version.stdout.decode("utf-8").removeprefix("git version ").split(".") 139 | if (int(v[0]), int(v[1])) < (2, 28): 140 | warnings.warn( 141 | "You need git 2.28.0 or newer to run the full test suite.", 142 | UserWarning, 143 | stacklevel=2, 144 | ) 145 | git_config("--local", "user.name", "Monty Python") 146 | git_config("--local", "user.email", "bot@python.org") 147 | git_config("--local", "commit.gpgSign", "false") 148 | git_commit("Initial commit", "--allow-empty") 149 | yield repo_dir 150 | 151 | 152 | @mock.patch("subprocess.check_output") 153 | def test_get_base_branch(subprocess_check_output, config): 154 | # The format of cherry-pick branches we create are:: 155 | # backport-{SHA}-{base_branch} 156 | subprocess_check_output.return_value = b"22a594a0047d7706537ff2ac676cdc0f1dcb329c" 157 | cherry_pick_branch = "backport-22a594a-2.7" 158 | result = get_base_branch(cherry_pick_branch, config=config) 159 | assert result == "2.7" 160 | 161 | 162 | @mock.patch("subprocess.check_output") 163 | def test_get_base_branch_which_has_dashes(subprocess_check_output, config): 164 | subprocess_check_output.return_value = b"22a594a0047d7706537ff2ac676cdc0f1dcb329c" 165 | cherry_pick_branch = "backport-22a594a-baseprefix-2.7-basesuffix" 166 | result = get_base_branch(cherry_pick_branch, config=config) 167 | assert result == "baseprefix-2.7-basesuffix" 168 | 169 | 170 | @pytest.mark.parametrize( 171 | "cherry_pick_branch", 172 | [ 173 | "backport-22a594a", # Not enough fields 174 | "prefix-22a594a-2.7", # Not the prefix we were expecting 175 | "backport-22a594a-", # No base branch 176 | ], 177 | ) 178 | @mock.patch("subprocess.check_output") 179 | def test_get_base_branch_invalid(subprocess_check_output, cherry_pick_branch, config): 180 | subprocess_check_output.return_value = b"22a594a0047d7706537ff2ac676cdc0f1dcb329c" 181 | with pytest.raises(ValueError): 182 | get_base_branch(cherry_pick_branch, config=config) 183 | 184 | 185 | @mock.patch("subprocess.check_output") 186 | def test_get_current_branch(subprocess_check_output): 187 | subprocess_check_output.return_value = b"main" 188 | assert get_current_branch() == "main" 189 | 190 | 191 | @mock.patch("subprocess.check_output") 192 | def test_get_full_sha_from_short(subprocess_check_output): 193 | mock_output = b"""22a594a0047d7706537ff2ac676cdc0f1dcb329c""" 194 | subprocess_check_output.return_value = mock_output 195 | assert ( 196 | get_full_sha_from_short("22a594a") == "22a594a0047d7706537ff2ac676cdc0f1dcb329c" 197 | ) 198 | 199 | 200 | @mock.patch("subprocess.check_output") 201 | def test_get_author_info_from_short_sha(subprocess_check_output): 202 | mock_output = b"Armin Rigo " 203 | subprocess_check_output.return_value = mock_output 204 | assert ( 205 | get_author_info_from_short_sha("22a594a") == "Armin Rigo " 206 | ) 207 | 208 | 209 | @pytest.mark.parametrize( 210 | "input_branches,sorted_branches,require_version", 211 | [ 212 | (["3.1", "2.7", "3.10", "3.6"], ["3.10", "3.6", "3.1", "2.7"], True), 213 | ( 214 | ["stable-3.1", "lts-2.7", "3.10-other", "smth3.6else"], 215 | ["3.10-other", "smth3.6else", "stable-3.1", "lts-2.7"], 216 | True, 217 | ), 218 | (["3.1", "2.7", "3.10", "3.6"], ["3.10", "3.6", "3.1", "2.7"], False), 219 | ( 220 | ["stable-3.1", "lts-2.7", "3.10-other", "smth3.6else"], 221 | ["3.10-other", "smth3.6else", "stable-3.1", "lts-2.7"], 222 | False, 223 | ), 224 | ( 225 | ["3.7", "3.10", "2.7", "foo", "stable", "branch"], 226 | ["3.10", "3.7", "2.7", "branch", "foo", "stable"], 227 | False, 228 | ), 229 | ], 230 | ) 231 | @mock.patch("os.path.exists") 232 | def test_sorted_branch( 233 | os_path_exists, config, input_branches, sorted_branches, require_version 234 | ): 235 | os_path_exists.return_value = True 236 | config["require_version_in_branch_name"] = require_version 237 | cp = CherryPicker( 238 | "origin", 239 | "22a594a0047d7706537ff2ac676cdc0f1dcb329c", 240 | input_branches, 241 | config=config, 242 | ) 243 | assert cp.sorted_branches == sorted_branches 244 | 245 | 246 | @mock.patch("os.path.exists") 247 | def test_invalid_branch_empty_string(os_path_exists, config): 248 | os_path_exists.return_value = True 249 | # already tested for require_version_in_branch_name=True below 250 | config["require_version_in_branch_name"] = False 251 | cp = CherryPicker( 252 | "origin", 253 | "22a594a0047d7706537ff2ac676cdc0f1dcb329c", 254 | ["3.1", "2.7", "3.10", "3.6", ""], 255 | config=config, 256 | ) 257 | with pytest.raises(ValueError, match=r"^Branch name is an empty string\.$"): 258 | cp.sorted_branches 259 | 260 | 261 | @pytest.mark.parametrize( 262 | "input_branches", 263 | [ 264 | (["3.1", "2.7", "3.x10", "3.6", ""]), 265 | (["stable-3.1", "lts-2.7", "3.10-other", "smth3.6else", "invalid"]), 266 | ], 267 | ) 268 | @mock.patch("os.path.exists") 269 | def test_invalid_branches(os_path_exists, config, input_branches): 270 | os_path_exists.return_value = True 271 | cp = CherryPicker( 272 | "origin", 273 | "22a594a0047d7706537ff2ac676cdc0f1dcb329c", 274 | input_branches, 275 | config=config, 276 | ) 277 | with pytest.raises(ValueError): 278 | cp.sorted_branches 279 | 280 | 281 | @mock.patch("os.path.exists") 282 | def test_get_cherry_pick_branch(os_path_exists, config): 283 | os_path_exists.return_value = True 284 | branches = ["3.6"] 285 | cp = CherryPicker( 286 | "origin", "22a594a0047d7706537ff2ac676cdc0f1dcb329c", branches, config=config 287 | ) 288 | assert cp.get_cherry_pick_branch("3.6") == "backport-22a594a-3.6" 289 | 290 | 291 | @pytest.mark.parametrize( 292 | "remote_name,upstream_remote", 293 | ( 294 | ("upstream", None), 295 | ("upstream", "upstream"), 296 | ("origin", None), 297 | ("origin", "origin"), 298 | ("python", "python"), 299 | ), 300 | ) 301 | def test_upstream_name( 302 | remote_name, upstream_remote, config, tmp_git_repo_dir, git_remote 303 | ): 304 | git_remote("add", remote_name, "https://github.com/python/cpython.git") 305 | if remote_name != "origin": 306 | git_remote("add", "origin", "https://github.com/miss-islington/cpython.git") 307 | 308 | branches = ["3.6"] 309 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 310 | cp = CherryPicker( 311 | "origin", 312 | "22a594a0047d7706537ff2ac676cdc0f1dcb329c", 313 | branches, 314 | config=config, 315 | upstream_remote=upstream_remote, 316 | ) 317 | assert cp.upstream == remote_name 318 | 319 | 320 | @pytest.mark.parametrize( 321 | "remote_to_add,remote_name,upstream_remote", 322 | ( 323 | (None, "upstream", None), 324 | ("origin", "upstream", "upstream"), 325 | (None, "origin", None), 326 | ("upstream", "origin", "origin"), 327 | ("origin", "python", "python"), 328 | (None, "python", None), 329 | ), 330 | ) 331 | def test_error_on_missing_remote( 332 | remote_to_add, remote_name, upstream_remote, config, tmp_git_repo_dir, git_remote 333 | ): 334 | git_remote("add", "some-remote-name", "https://github.com/python/cpython.git") 335 | if remote_to_add is not None: 336 | git_remote( 337 | "add", remote_to_add, "https://github.com/miss-islington/cpython.git" 338 | ) 339 | 340 | branches = ["3.6"] 341 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 342 | cp = CherryPicker( 343 | "origin", 344 | "22a594a0047d7706537ff2ac676cdc0f1dcb329c", 345 | branches, 346 | config=config, 347 | upstream_remote=upstream_remote, 348 | ) 349 | with pytest.raises(ValueError): 350 | cp.upstream 351 | 352 | 353 | def test_get_pr_url(config): 354 | branches = ["3.6"] 355 | 356 | cp = CherryPicker( 357 | "origin", "22a594a0047d7706537ff2ac676cdc0f1dcb329c", branches, config=config 358 | ) 359 | backport_target_branch = cp.get_cherry_pick_branch("3.6") 360 | expected_pr_url = ( 361 | "https://github.com/python/cpython/compare/" 362 | "3.6...mock_user:backport-22a594a-3.6?expand=1" 363 | ) 364 | with mock.patch( 365 | "subprocess.check_output", 366 | return_value=b"https://github.com/mock_user/cpython.git", 367 | ): 368 | actual_pr_url = cp.get_pr_url("3.6", backport_target_branch) 369 | 370 | assert actual_pr_url == expected_pr_url 371 | 372 | 373 | @pytest.mark.parametrize( 374 | "url", 375 | [ 376 | b"git@github.com:mock_user/cpython.git", 377 | b"git@github.com:mock_user/cpython", 378 | b"git@github.com:mock_user/cpython/", 379 | b"ssh://git@github.com/mock_user/cpython.git", 380 | b"ssh://git@github.com/mock_user/cpython", 381 | b"ssh://git@github.com/mock_user/cpython/", 382 | b"https://github.com/mock_user/cpython.git", 383 | b"https://github.com/mock_user/cpython", 384 | b"https://github.com/mock_user/cpython/", 385 | # test trailing whitespace 386 | b"https://github.com/mock_user/cpython.git\n", 387 | b"https://github.com/mock_user/cpython\n", 388 | b"https://github.com/mock_user/cpython/\n", 389 | ], 390 | ) 391 | def test_username(url, config): 392 | branches = ["3.6"] 393 | cp = CherryPicker( 394 | "origin", "22a594a0047d7706537ff2ac676cdc0f1dcb329c", branches, config=config 395 | ) 396 | with mock.patch("subprocess.check_output", return_value=url): 397 | assert cp.username == "mock_user" 398 | 399 | 400 | def test_get_updated_commit_message(config): 401 | branches = ["3.6"] 402 | cp = CherryPicker( 403 | "origin", "22a594a0047d7706537ff2ac676cdc0f1dcb329c", branches, config=config 404 | ) 405 | with mock.patch( 406 | "subprocess.check_output", 407 | return_value=b"bpo-123: Fix#12345 #1234 #12345Number Sign (#01234) (#11345)", 408 | ): 409 | actual_commit_message = cp.get_commit_message( 410 | "22a594a0047d7706537ff2ac676cdc0f1dcb329c" 411 | ) 412 | assert ( 413 | actual_commit_message 414 | == "bpo-123: Fix#12345 #1234 #12345Number Sign (#01234) (GH-11345)" 415 | ) 416 | 417 | 418 | def test_get_updated_commit_message_without_links_replacement(config): 419 | config["fix_commit_msg"] = False 420 | branches = ["3.6"] 421 | cp = CherryPicker( 422 | "origin", "22a594a0047d7706537ff2ac676cdc0f1dcb329c", branches, config=config 423 | ) 424 | with mock.patch( 425 | "subprocess.check_output", return_value=b"bpo-123: Fix Spam Module (#113)" 426 | ): 427 | actual_commit_message = cp.get_commit_message( 428 | "22a594a0047d7706537ff2ac676cdc0f1dcb329c" 429 | ) 430 | assert actual_commit_message == "bpo-123: Fix Spam Module (#113)" 431 | 432 | 433 | @mock.patch("subprocess.check_output") 434 | def test_is_cpython_repo(subprocess_check_output): 435 | subprocess_check_output.return_value = """\ 436 | commit 7f777ed95a19224294949e1b4ce56bbffcb1fe9f 437 | Author: Guido van Rossum 438 | Date: Thu Aug 9 14:25:15 1990 +0000 439 | 440 | Initial revision 441 | 442 | """ 443 | # should not raise an exception 444 | validate_sha("22a594a0047d7706537ff2ac676cdc0f1dcb329c") 445 | 446 | 447 | def test_is_not_cpython_repo(): 448 | # use default CPython sha to fail on this repo 449 | with pytest.raises( 450 | InvalidRepoException, 451 | match=r"The sha listed in the branch name, " 452 | r"\w+, is not present in the repository", 453 | ): 454 | CherryPicker("origin", "22a594a0047d7706537ff2ac676cdc0f1dcb329c", ["3.6"]) 455 | 456 | 457 | def test_find_config(tmp_git_repo_dir, git_add, git_commit): 458 | relative_config_path = ".cherry_picker.toml" 459 | tmp_git_repo_dir.join(relative_config_path).write("param = 1") 460 | git_add(relative_config_path) 461 | git_commit("Add config") 462 | scm_revision = get_sha1_from("HEAD") 463 | assert find_config(scm_revision) == f"{scm_revision}:{relative_config_path}" 464 | 465 | 466 | def test_find_config_not_found(tmp_git_repo_dir): 467 | scm_revision = get_sha1_from("HEAD") 468 | assert find_config(scm_revision) is None 469 | 470 | 471 | def test_find_config_not_git(tmpdir, cd): 472 | cd(tmpdir) 473 | assert find_config(None) is None 474 | 475 | 476 | def test_load_full_config(tmp_git_repo_dir, git_add, git_commit): 477 | relative_config_path = ".cherry_picker.toml" 478 | tmp_git_repo_dir.join(relative_config_path).write( 479 | """\ 480 | team = "python" 481 | repo = "core-workfolow" 482 | check_sha = "5f007046b5d4766f971272a0cc99f8461215c1ec" 483 | default_branch = "devel" 484 | """ 485 | ) 486 | git_add(relative_config_path) 487 | git_commit("Add config") 488 | scm_revision = get_sha1_from("HEAD") 489 | cfg = load_config(None) 490 | assert cfg == ( 491 | scm_revision + ":" + relative_config_path, 492 | { 493 | "check_sha": "5f007046b5d4766f971272a0cc99f8461215c1ec", 494 | "repo": "core-workfolow", 495 | "team": "python", 496 | "fix_commit_msg": True, 497 | "default_branch": "devel", 498 | "require_version_in_branch_name": True, 499 | "draft_pr": False, 500 | }, 501 | ) 502 | 503 | 504 | def test_load_partial_config(tmp_git_repo_dir, git_add, git_commit): 505 | relative_config_path = ".cherry_picker.toml" 506 | tmp_git_repo_dir.join(relative_config_path).write( 507 | """\ 508 | repo = "core-workfolow" 509 | """ 510 | ) 511 | git_add(relative_config_path) 512 | git_commit("Add config") 513 | scm_revision = get_sha1_from("HEAD") 514 | cfg = load_config(relative_config_path) 515 | assert cfg == ( 516 | f"{scm_revision}:{relative_config_path}", 517 | { 518 | "check_sha": "7f777ed95a19224294949e1b4ce56bbffcb1fe9f", 519 | "repo": "core-workfolow", 520 | "team": "python", 521 | "fix_commit_msg": True, 522 | "default_branch": "main", 523 | "require_version_in_branch_name": True, 524 | "draft_pr": False, 525 | }, 526 | ) 527 | 528 | 529 | def test_load_config_no_head_sha(tmp_git_repo_dir, git_add, git_commit): 530 | relative_config_path = ".cherry_picker.toml" 531 | tmp_git_repo_dir.join(relative_config_path).write( 532 | """\ 533 | team = "python" 534 | repo = "core-workfolow" 535 | check_sha = "5f007046b5d4766f971272a0cc99f8461215c1ec" 536 | default_branch = "devel" 537 | """ 538 | ) 539 | git_add(relative_config_path) 540 | git_commit(f"Add {relative_config_path}") 541 | 542 | with mock.patch("cherry_picker.cherry_picker.get_sha1_from", return_value=""): 543 | cfg = load_config(relative_config_path) 544 | 545 | assert cfg == ( 546 | ":" + relative_config_path, 547 | { 548 | "check_sha": "5f007046b5d4766f971272a0cc99f8461215c1ec", 549 | "repo": "core-workfolow", 550 | "team": "python", 551 | "fix_commit_msg": True, 552 | "default_branch": "devel", 553 | "require_version_in_branch_name": True, 554 | "draft_pr": False, 555 | }, 556 | ) 557 | 558 | 559 | def test_normalize_long_commit_message(): 560 | commit_message = """\ 561 | [3.6] Fix broken `Show Source` links on documentation pages (GH-3113) 562 | 563 | The `Show Source` was broken because of a change made in sphinx 1.5.1 564 | In Sphinx 1.4.9, the sourcename was "index.txt". 565 | In Sphinx 1.5.1+, it is now "index.rst.txt". 566 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 567 | 568 | 569 | Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" 570 | title, body = normalize_commit_message(commit_message) 571 | assert ( 572 | title == "[3.6] Fix broken `Show Source` links on documentation pages (GH-3113)" 573 | ) 574 | assert ( 575 | body 576 | == """The `Show Source` was broken because of a change made in sphinx 1.5.1 577 | In Sphinx 1.4.9, the sourcename was "index.txt". 578 | In Sphinx 1.5.1+, it is now "index.rst.txt". 579 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 580 | 581 | 582 | Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" 583 | ) 584 | 585 | 586 | def test_normalize_short_commit_message(): 587 | commit_message = """\ 588 | [3.6] Fix broken `Show Source` links on documentation pages (GH-3113) 589 | 590 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 591 | 592 | 593 | Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" 594 | title, body = normalize_commit_message(commit_message) 595 | assert ( 596 | title == "[3.6] Fix broken `Show Source` links on documentation pages (GH-3113)" 597 | ) 598 | assert ( 599 | body 600 | == """(cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 601 | 602 | 603 | Co-authored-by: Elmar Ritsch <35851+elritsch@users.noreply.github.com>""" 604 | ) 605 | 606 | 607 | @pytest.mark.parametrize( 608 | "commit_message, expected", 609 | [ 610 | ("[3.12] Fix something (GH-3113)", "Fix something (GH-3113)"), 611 | ("[3.11] [3.12] Fix something (GH-3113)", "Fix something (GH-3113)"), 612 | ("Fix something (GH-3113)", "Fix something (GH-3113)"), 613 | ("[WIP] Fix something (GH-3113)", "[WIP] Fix something (GH-3113)"), 614 | ], 615 | ) 616 | def test_remove_commit_prefix(commit_message, expected): 617 | assert remove_commit_prefix(commit_message) == expected 618 | 619 | 620 | @pytest.mark.parametrize( 621 | "commit_message,expected_commit_message", 622 | ( 623 | # ensure existing co-author is retained 624 | ( 625 | """Fix broken `Show Source` links on documentation pages (GH-3113) 626 | 627 | Co-authored-by: PR Co-Author """, 628 | """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) 629 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 630 | 631 | Co-authored-by: PR Author 632 | Co-authored-by: PR Co-Author """, 633 | ), 634 | # ensure co-author trailer is not duplicated 635 | ( 636 | """Fix broken `Show Source` links on documentation pages (GH-3113) 637 | 638 | Co-authored-by: PR Author """, 639 | """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) 640 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 641 | 642 | Co-authored-by: PR Author """, 643 | ), 644 | # ensure message is formatted properly when original commit is short 645 | ( 646 | "Fix broken `Show Source` links on documentation pages (GH-3113)", 647 | """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) 648 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 649 | 650 | Co-authored-by: PR Author """, 651 | ), 652 | # ensure message is formatted properly when original commit is long 653 | ( 654 | """Fix broken `Show Source` links on documentation pages (GH-3113) 655 | 656 | The `Show Source` was broken because of a change made in sphinx 1.5.1 657 | In Sphinx 1.4.9, the sourcename was "index.txt". 658 | In Sphinx 1.5.1+, it is now "index.rst.txt".""", 659 | """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) 660 | 661 | The `Show Source` was broken because of a change made in sphinx 1.5.1 662 | In Sphinx 1.4.9, the sourcename was "index.txt". 663 | In Sphinx 1.5.1+, it is now "index.rst.txt". 664 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 665 | 666 | Co-authored-by: PR Author """, 667 | ), 668 | # ensure message is formatted properly when original commit is long 669 | # and it has a co-author 670 | ( 671 | """Fix broken `Show Source` links on documentation pages (GH-3113) 672 | 673 | The `Show Source` was broken because of a change made in sphinx 1.5.1 674 | In Sphinx 1.4.9, the sourcename was "index.txt". 675 | In Sphinx 1.5.1+, it is now "index.rst.txt". 676 | 677 | Co-authored-by: PR Co-Author """, 678 | """[3.6] Fix broken `Show Source` links on documentation pages (GH-3113) 679 | 680 | The `Show Source` was broken because of a change made in sphinx 1.5.1 681 | In Sphinx 1.4.9, the sourcename was "index.txt". 682 | In Sphinx 1.5.1+, it is now "index.rst.txt". 683 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 684 | 685 | Co-authored-by: PR Author 686 | Co-authored-by: PR Co-Author """, 687 | ), 688 | # ensure the existing commit prefix is replaced 689 | ( 690 | "[3.7] [3.8] Fix broken `Show Source` links on documentation " 691 | "pages (GH-3113) (GH-3114) (GH-3115)", 692 | """[3.6] Fix broken `Show Source` links on documentation """ 693 | """pages (GH-3113) (GH-3114) (GH-3115) 694 | (cherry picked from commit b9ff498793611d1c6a9b99df464812931a1e2d69) 695 | 696 | Co-authored-by: PR Author """, 697 | ), 698 | ), 699 | ) 700 | def test_get_updated_commit_message_with_trailers( 701 | commit_message, expected_commit_message 702 | ): 703 | cherry_pick_branch = "backport-22a594a-3.6" 704 | commit = "b9ff498793611d1c6a9b99df464812931a1e2d69" 705 | 706 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 707 | cherry_picker = CherryPicker("origin", commit, []) 708 | 709 | with ( 710 | mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True), 711 | mock.patch.object( 712 | cherry_picker, "get_commit_message", return_value=commit_message 713 | ), 714 | mock.patch( 715 | "cherry_picker.cherry_picker.get_author_info_from_short_sha", 716 | return_value="PR Author ", 717 | ), 718 | ): 719 | updated_commit_message = cherry_picker.get_updated_commit_message( 720 | cherry_pick_branch 721 | ) 722 | 723 | assert updated_commit_message == expected_commit_message 724 | 725 | 726 | @pytest.mark.parametrize( 727 | "input_path", ("/some/path/without/revision", "HEAD:some/non-existent/path") 728 | ) 729 | def test_from_git_rev_read_negative(input_path, tmp_git_repo_dir): 730 | with pytest.raises(ValueError): 731 | from_git_rev_read(input_path) 732 | 733 | 734 | def test_from_git_rev_read_uncommitted(tmp_git_repo_dir, git_add, git_commit): 735 | some_text = "blah blah 🤖" 736 | relative_file_path = ".some.file" 737 | (pathlib.Path(tmp_git_repo_dir) / relative_file_path).write_text( 738 | some_text, encoding="utf-8" 739 | ) 740 | git_add(".") 741 | with pytest.raises(ValueError): 742 | from_git_rev_read("HEAD:" + relative_file_path) 743 | 744 | 745 | def test_from_git_rev_read(tmp_git_repo_dir, git_add, git_commit): 746 | some_text = "blah blah 🤖" 747 | relative_file_path = ".some.file" 748 | (pathlib.Path(tmp_git_repo_dir) / relative_file_path).write_text( 749 | some_text, encoding="utf-8" 750 | ) 751 | git_add(".") 752 | git_commit("Add some file") 753 | assert from_git_rev_read("HEAD:" + relative_file_path) == some_text 754 | 755 | 756 | def test_states(tmp_git_repo_dir): 757 | class state_val: 758 | name = "somerandomwords" 759 | 760 | # First, verify that there's nothing there initially 761 | assert get_state() == WORKFLOW_STATES.UNSET 762 | 763 | # Now, set some val 764 | set_state(state_val) 765 | with pytest.raises(KeyError, match=state_val.name): 766 | get_state() 767 | 768 | # Wipe it again 769 | reset_state() 770 | assert get_state() == WORKFLOW_STATES.UNSET 771 | 772 | 773 | def test_paused_flow(tmp_git_repo_dir, git_add, git_commit): 774 | assert load_val_from_git_cfg("config_path") is None 775 | initial_scm_revision = get_sha1_from("HEAD") 776 | 777 | relative_file_path = "some.toml" 778 | tmp_git_repo_dir.join(relative_file_path).write( 779 | f"""\ 780 | check_sha = "{initial_scm_revision}" 781 | repo = "core-workfolow" 782 | """ 783 | ) 784 | git_add(relative_file_path) 785 | git_commit("Add a config") 786 | config_scm_revision = get_sha1_from("HEAD") 787 | 788 | config_path_rev = config_scm_revision + ":" + relative_file_path 789 | chosen_config_path, config = load_config(config_path_rev) 790 | 791 | cherry_picker = CherryPicker( 792 | "origin", 793 | config_scm_revision, 794 | [], 795 | config=config, 796 | chosen_config_path=chosen_config_path, 797 | ) 798 | assert get_state() == WORKFLOW_STATES.UNSET 799 | 800 | cherry_picker.set_paused_state() 801 | assert load_val_from_git_cfg("config_path") == config_path_rev 802 | assert get_state() == WORKFLOW_STATES.BACKPORT_PAUSED 803 | 804 | chosen_config_path, config = load_config(None) 805 | assert chosen_config_path == config_path_rev 806 | 807 | reset_stored_config_ref() 808 | assert load_val_from_git_cfg("config_path") is None 809 | 810 | 811 | @pytest.mark.parametrize( 812 | "method_name,start_state,end_state", 813 | ( 814 | ( 815 | "fetch_upstream", 816 | WORKFLOW_STATES.FETCHING_UPSTREAM, 817 | WORKFLOW_STATES.FETCHED_UPSTREAM, 818 | ), 819 | ( 820 | "checkout_default_branch", 821 | WORKFLOW_STATES.CHECKING_OUT_DEFAULT_BRANCH, 822 | WORKFLOW_STATES.CHECKED_OUT_DEFAULT_BRANCH, 823 | ), 824 | ( 825 | "checkout_previous_branch", 826 | WORKFLOW_STATES.CHECKING_OUT_PREVIOUS_BRANCH, 827 | WORKFLOW_STATES.CHECKED_OUT_PREVIOUS_BRANCH, 828 | ), 829 | ), 830 | ) 831 | def test_start_end_states(method_name, start_state, end_state, tmp_git_repo_dir): 832 | assert get_state() == WORKFLOW_STATES.UNSET 833 | 834 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 835 | cherry_picker = CherryPicker("origin", "xxx", []) 836 | cherry_picker.remember_previous_branch() 837 | assert get_state() == WORKFLOW_STATES.UNSET 838 | 839 | def _fetch(cmd, *args, **kwargs): 840 | assert get_state() == start_state 841 | 842 | with mock.patch.object(cherry_picker, "run_cmd", _fetch): 843 | getattr(cherry_picker, method_name)() 844 | assert get_state() == end_state 845 | 846 | 847 | def test_cleanup_branch(tmp_git_repo_dir, git_checkout): 848 | assert get_state() == WORKFLOW_STATES.UNSET 849 | 850 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 851 | cherry_picker = CherryPicker("origin", "xxx", []) 852 | assert get_state() == WORKFLOW_STATES.UNSET 853 | 854 | git_checkout("-b", "some_branch") 855 | cherry_picker.cleanup_branch("some_branch") 856 | assert get_state() == WORKFLOW_STATES.REMOVED_BACKPORT_BRANCH 857 | assert get_current_branch() == "main" 858 | 859 | 860 | def test_cleanup_branch_checkout_previous_branch( 861 | tmp_git_repo_dir, git_checkout, git_worktree 862 | ): 863 | assert get_state() == WORKFLOW_STATES.UNSET 864 | 865 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 866 | cherry_picker = CherryPicker("origin", "xxx", []) 867 | assert get_state() == WORKFLOW_STATES.UNSET 868 | 869 | git_checkout("-b", "previous_branch") 870 | cherry_picker.remember_previous_branch() 871 | git_checkout("-b", "some_branch") 872 | cherry_picker.cleanup_branch("some_branch") 873 | assert get_state() == WORKFLOW_STATES.REMOVED_BACKPORT_BRANCH 874 | assert get_current_branch() == "previous_branch" 875 | 876 | 877 | def test_cleanup_branch_fail(tmp_git_repo_dir): 878 | assert get_state() == WORKFLOW_STATES.UNSET 879 | 880 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 881 | cherry_picker = CherryPicker("origin", "xxx", []) 882 | assert get_state() == WORKFLOW_STATES.UNSET 883 | 884 | cherry_picker.cleanup_branch("some_branch") 885 | assert get_state() == WORKFLOW_STATES.REMOVING_BACKPORT_BRANCH_FAILED 886 | 887 | 888 | def test_cleanup_branch_checkout_fail( 889 | tmp_git_repo_dir, tmpdir, git_checkout, git_worktree 890 | ): 891 | assert get_state() == WORKFLOW_STATES.UNSET 892 | 893 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 894 | cherry_picker = CherryPicker("origin", "xxx", []) 895 | assert get_state() == WORKFLOW_STATES.UNSET 896 | 897 | git_checkout("-b", "some_branch") 898 | git_worktree("add", str(tmpdir.mkdir("test-worktree")), "main") 899 | cherry_picker.cleanup_branch("some_branch") 900 | assert get_state() == WORKFLOW_STATES.REMOVING_BACKPORT_BRANCH_FAILED 901 | 902 | 903 | def test_cherry_pick(tmp_git_repo_dir, git_add, git_branch, git_commit, git_checkout): 904 | cherry_pick_target_branches = ("3.8",) 905 | pr_remote = "origin" 906 | test_file = "some.file" 907 | tmp_git_repo_dir.join(test_file).write("some contents") 908 | git_branch(cherry_pick_target_branches[0]) 909 | git_branch( 910 | f"{pr_remote}/{cherry_pick_target_branches[0]}", cherry_pick_target_branches[0] 911 | ) 912 | git_add(test_file) 913 | git_commit("Add a test file") 914 | scm_revision = get_sha1_from("HEAD") 915 | 916 | git_checkout(cherry_pick_target_branches[0]) # simulate backport method logic 917 | 918 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 919 | cherry_picker = CherryPicker( 920 | pr_remote, scm_revision, cherry_pick_target_branches 921 | ) 922 | 923 | cherry_picker.cherry_pick() 924 | 925 | 926 | def test_cherry_pick_fail( 927 | tmp_git_repo_dir, 928 | ): 929 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 930 | cherry_picker = CherryPicker("origin", "xxx", []) 931 | 932 | with pytest.raises(CherryPickException, match="^Error cherry-pick xxx.$"): 933 | cherry_picker.cherry_pick() 934 | 935 | 936 | def test_get_state_and_verify_fail( 937 | tmp_git_repo_dir, 938 | ): 939 | class tested_state: 940 | name = "invalid_state" 941 | 942 | set_state(tested_state) 943 | 944 | expected_msg_regexp = ( 945 | rf"^Run state cherry-picker.state={tested_state.name} in Git config " 946 | r"is not known." 947 | "\n" 948 | r"Perhaps it has been set by a newer " 949 | r"version of cherry-picker\. Try upgrading\." 950 | "\n" 951 | r"Valid states are: " 952 | r"[\w_\s]+(, [\w_\s]+)*\. " 953 | r"If this looks suspicious, raise an issue at " 954 | r"https://github.com/python/cherry-picker/issues/new\." 955 | "\n" 956 | r"As the last resort you can reset the runtime state " 957 | r"stored in Git config using the following command: " 958 | r"`git config --local --remove-section cherry-picker`" 959 | ) 960 | with ( 961 | mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True), 962 | pytest.raises(InvalidRepoException, match=expected_msg_regexp), 963 | ): 964 | CherryPicker("origin", "xxx", []) 965 | 966 | 967 | def test_push_to_remote_fail(tmp_git_repo_dir): 968 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 969 | cherry_picker = CherryPicker("origin", "xxx", []) 970 | 971 | cherry_picker.push_to_remote("main", "backport-branch-test") 972 | assert get_state() == WORKFLOW_STATES.PUSHING_TO_REMOTE_FAILED 973 | 974 | 975 | def test_push_to_remote_interactive(tmp_git_repo_dir): 976 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 977 | cherry_picker = CherryPicker("origin", "xxx", []) 978 | 979 | with ( 980 | mock.patch.object(cherry_picker, "run_cmd"), 981 | mock.patch.object(cherry_picker, "open_pr"), 982 | mock.patch.object(cherry_picker, "get_pr_url", return_value="https://pr_url"), 983 | ): 984 | cherry_picker.push_to_remote("main", "backport-branch-test") 985 | assert get_state() == WORKFLOW_STATES.PR_OPENING 986 | 987 | 988 | def test_push_to_remote_botflow(tmp_git_repo_dir, monkeypatch): 989 | monkeypatch.setenv("GH_AUTH", "True") 990 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 991 | cherry_picker = CherryPicker("origin", "xxx", []) 992 | 993 | with ( 994 | mock.patch.object(cherry_picker, "run_cmd"), 995 | mock.patch.object(cherry_picker, "create_gh_pr"), 996 | ): 997 | cherry_picker.push_to_remote("main", "backport-branch-test") 998 | assert get_state() == WORKFLOW_STATES.PR_CREATING 999 | 1000 | 1001 | def test_push_to_remote_no_auto_pr(tmp_git_repo_dir, monkeypatch): 1002 | monkeypatch.setenv("GH_AUTH", "True") 1003 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1004 | cherry_picker = CherryPicker("origin", "xxx", [], auto_pr=False) 1005 | 1006 | with ( 1007 | mock.patch.object(cherry_picker, "run_cmd"), 1008 | mock.patch.object(cherry_picker, "create_gh_pr"), 1009 | ): 1010 | cherry_picker.push_to_remote("main", "backport-branch-test") 1011 | assert get_state() == WORKFLOW_STATES.PUSHED_TO_REMOTE 1012 | 1013 | 1014 | def test_backport_no_branch(tmp_git_repo_dir, monkeypatch): 1015 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1016 | cherry_picker = CherryPicker("origin", "xxx", []) 1017 | 1018 | with pytest.raises( 1019 | click.UsageError, match="^At least one branch must be specified.$" 1020 | ): 1021 | cherry_picker.backport() 1022 | 1023 | 1024 | def test_backport_cherry_pick_fail( 1025 | tmp_git_repo_dir, git_branch, git_add, git_commit, git_checkout 1026 | ): 1027 | cherry_pick_target_branches = ("3.8",) 1028 | pr_remote = "origin" 1029 | test_file = "some.file" 1030 | tmp_git_repo_dir.join(test_file).write("some contents") 1031 | git_branch(cherry_pick_target_branches[0]) 1032 | git_branch( 1033 | f"{pr_remote}/{cherry_pick_target_branches[0]}", cherry_pick_target_branches[0] 1034 | ) 1035 | git_add(test_file) 1036 | git_commit("Add a test file") 1037 | scm_revision = get_sha1_from("HEAD") 1038 | 1039 | git_checkout(cherry_pick_target_branches[0]) # simulate backport method logic 1040 | 1041 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1042 | cherry_picker = CherryPicker( 1043 | pr_remote, scm_revision, cherry_pick_target_branches 1044 | ) 1045 | 1046 | with ( 1047 | pytest.raises(CherryPickException), 1048 | mock.patch.object(cherry_picker, "checkout_branch"), 1049 | mock.patch.object(cherry_picker, "fetch_upstream"), 1050 | mock.patch.object( 1051 | cherry_picker, "cherry_pick", side_effect=CherryPickException 1052 | ), 1053 | ): 1054 | cherry_picker.backport() 1055 | 1056 | assert get_state() == WORKFLOW_STATES.BACKPORT_PAUSED 1057 | 1058 | 1059 | def test_backport_cherry_pick_crash_ignored( 1060 | tmp_git_repo_dir, git_branch, git_add, git_commit, git_checkout 1061 | ): 1062 | cherry_pick_target_branches = ("3.8",) 1063 | pr_remote = "origin" 1064 | test_file = "some.file" 1065 | tmp_git_repo_dir.join(test_file).write("some contents") 1066 | git_branch(cherry_pick_target_branches[0]) 1067 | git_branch( 1068 | f"{pr_remote}/{cherry_pick_target_branches[0]}", cherry_pick_target_branches[0] 1069 | ) 1070 | git_add(test_file) 1071 | git_commit("Add a test file") 1072 | scm_revision = get_sha1_from("HEAD") 1073 | 1074 | git_checkout(cherry_pick_target_branches[0]) # simulate backport method logic 1075 | 1076 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1077 | cherry_picker = CherryPicker( 1078 | pr_remote, scm_revision, cherry_pick_target_branches 1079 | ) 1080 | 1081 | with ( 1082 | mock.patch.object(cherry_picker, "checkout_branch"), 1083 | mock.patch.object(cherry_picker, "fetch_upstream"), 1084 | mock.patch.object(cherry_picker, "cherry_pick"), 1085 | mock.patch.object( 1086 | cherry_picker, 1087 | "amend_commit_message", 1088 | side_effect=subprocess.CalledProcessError( 1089 | 1, ("git", "commit", "-am", "new commit message") 1090 | ), 1091 | ), 1092 | ): 1093 | cherry_picker.backport() 1094 | 1095 | assert get_state() == WORKFLOW_STATES.UNSET 1096 | 1097 | 1098 | def test_backport_cherry_pick_branch_already_exists( 1099 | tmp_git_repo_dir, git_branch, git_add, git_commit, git_checkout, git_remote 1100 | ): 1101 | cherry_pick_target_branches = ("3.8",) 1102 | pr_remote = "origin" 1103 | test_file = "some.file" 1104 | tmp_git_repo_dir.join(test_file).write("some contents") 1105 | git_remote("add", pr_remote, "https://github.com/python/cpython.git") 1106 | git_branch(cherry_pick_target_branches[0]) 1107 | git_branch( 1108 | f"{pr_remote}/{cherry_pick_target_branches[0]}", cherry_pick_target_branches[0] 1109 | ) 1110 | git_add(test_file) 1111 | git_commit("Add a test file") 1112 | scm_revision = get_sha1_from("HEAD") 1113 | 1114 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1115 | cherry_picker = CherryPicker( 1116 | pr_remote, scm_revision, cherry_pick_target_branches 1117 | ) 1118 | 1119 | backport_branch_name = cherry_picker.get_cherry_pick_branch( 1120 | cherry_pick_target_branches[0] 1121 | ) 1122 | git_branch(backport_branch_name) 1123 | 1124 | with ( 1125 | mock.patch.object(cherry_picker, "fetch_upstream"), 1126 | pytest.raises(BranchCheckoutException) as exc_info, 1127 | ): 1128 | cherry_picker.backport() 1129 | 1130 | assert exc_info.value.branch_name == backport_branch_name 1131 | assert get_state() == WORKFLOW_STATES.UNSET 1132 | 1133 | 1134 | def test_backport_success( 1135 | tmp_git_repo_dir, git_branch, git_add, git_commit, git_checkout 1136 | ): 1137 | cherry_pick_target_branches = ("3.8",) 1138 | pr_remote = "origin" 1139 | test_file = "some.file" 1140 | tmp_git_repo_dir.join(test_file).write("some contents") 1141 | git_branch(cherry_pick_target_branches[0]) 1142 | git_branch( 1143 | f"{pr_remote}/{cherry_pick_target_branches[0]}", cherry_pick_target_branches[0] 1144 | ) 1145 | git_add(test_file) 1146 | git_commit("Add a test file") 1147 | scm_revision = get_sha1_from("HEAD") 1148 | 1149 | git_checkout(cherry_pick_target_branches[0]) # simulate backport method logic 1150 | 1151 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1152 | cherry_picker = CherryPicker( 1153 | pr_remote, scm_revision, cherry_pick_target_branches 1154 | ) 1155 | 1156 | with ( 1157 | mock.patch.object(cherry_picker, "checkout_branch"), 1158 | mock.patch.object(cherry_picker, "fetch_upstream"), 1159 | mock.patch.object( 1160 | cherry_picker, "amend_commit_message", return_value="commit message" 1161 | ), 1162 | ): 1163 | cherry_picker.backport() 1164 | 1165 | assert get_state() == WORKFLOW_STATES.UNSET 1166 | 1167 | 1168 | @pytest.mark.parametrize("already_committed", (True, False)) 1169 | @pytest.mark.parametrize("push", (True, False)) 1170 | def test_backport_pause_and_continue( 1171 | tmp_git_repo_dir, 1172 | git_branch, 1173 | git_add, 1174 | git_commit, 1175 | git_checkout, 1176 | git_reset, 1177 | git_remote, 1178 | already_committed, 1179 | push, 1180 | ): 1181 | cherry_pick_target_branches = ("3.8",) 1182 | pr_remote = "origin" 1183 | test_file = "some.file" 1184 | tmp_git_repo_dir.join(test_file).write("some contents") 1185 | git_remote("add", pr_remote, "https://github.com/python/cpython.git") 1186 | git_branch(cherry_pick_target_branches[0]) 1187 | git_branch( 1188 | f"{pr_remote}/{cherry_pick_target_branches[0]}", cherry_pick_target_branches[0] 1189 | ) 1190 | git_add(test_file) 1191 | git_commit("Add a test file") 1192 | scm_revision = get_sha1_from("HEAD") 1193 | 1194 | git_checkout(cherry_pick_target_branches[0]) # simulate backport method logic 1195 | 1196 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1197 | cherry_picker = CherryPicker( 1198 | pr_remote, scm_revision, cherry_pick_target_branches, push=False 1199 | ) 1200 | 1201 | with ( 1202 | mock.patch.object(cherry_picker, "fetch_upstream"), 1203 | mock.patch.object( 1204 | cherry_picker, "amend_commit_message", return_value="commit message" 1205 | ), 1206 | ): 1207 | cherry_picker.backport() 1208 | 1209 | assert len(get_commits_from_backport_branch(cherry_pick_target_branches[0])) == 1 1210 | assert get_state() == WORKFLOW_STATES.BACKPORT_PAUSED 1211 | 1212 | if not already_committed: 1213 | git_reset("HEAD~1") 1214 | assert ( 1215 | len(get_commits_from_backport_branch(cherry_pick_target_branches[0])) == 0 1216 | ) 1217 | 1218 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1219 | cherry_picker = CherryPicker(pr_remote, "", [], push=push) 1220 | 1221 | commit_message = f"""[{cherry_pick_target_branches[0]}] commit message 1222 | (cherry picked from commit xxxxxxyyyyyy) 1223 | 1224 | 1225 | Co-authored-by: Author Name """ 1226 | 1227 | with ( 1228 | mock.patch("cherry_picker.cherry_picker.wipe_cfg_vals_from_git_cfg"), 1229 | mock.patch( 1230 | "cherry_picker.cherry_picker.get_full_sha_from_short", 1231 | return_value="xxxxxxyyyyyy", 1232 | ), 1233 | mock.patch("cherry_picker.cherry_picker.get_base_branch", return_value="3.8"), 1234 | mock.patch( 1235 | "cherry_picker.cherry_picker.get_current_branch", 1236 | return_value="backport-xxx-3.8", 1237 | ), 1238 | mock.patch.object( 1239 | cherry_picker, "amend_commit_message", return_value=commit_message 1240 | ) as amend_commit_message, 1241 | mock.patch.object( 1242 | cherry_picker, "get_updated_commit_message", return_value=commit_message 1243 | ) as get_updated_commit_message, 1244 | mock.patch.object(cherry_picker, "checkout_branch"), 1245 | mock.patch.object(cherry_picker, "fetch_upstream"), 1246 | mock.patch.object(cherry_picker, "cleanup_branch"), 1247 | ): 1248 | cherry_picker.continue_cherry_pick() 1249 | 1250 | if already_committed: 1251 | amend_commit_message.assert_called_once() 1252 | get_updated_commit_message.assert_not_called() 1253 | else: 1254 | get_updated_commit_message.assert_called_once() 1255 | amend_commit_message.assert_not_called() 1256 | 1257 | if push: 1258 | assert get_state() == WORKFLOW_STATES.BACKPORTING_CONTINUATION_SUCCEED 1259 | else: 1260 | assert get_state() == WORKFLOW_STATES.BACKPORT_PAUSED 1261 | 1262 | 1263 | def test_continue_cherry_pick_invalid_state(tmp_git_repo_dir): 1264 | assert get_state() == WORKFLOW_STATES.UNSET 1265 | 1266 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1267 | cherry_picker = CherryPicker("origin", "xxx", []) 1268 | 1269 | assert get_state() == WORKFLOW_STATES.UNSET 1270 | 1271 | with pytest.raises( 1272 | ValueError, match=re.compile(r"^One can only continue a paused process.") 1273 | ): 1274 | cherry_picker.continue_cherry_pick() 1275 | 1276 | assert get_state() == WORKFLOW_STATES.UNSET # success 1277 | 1278 | 1279 | def test_continue_cherry_pick_invalid_branch(tmp_git_repo_dir): 1280 | set_state(WORKFLOW_STATES.BACKPORT_PAUSED) 1281 | 1282 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1283 | cherry_picker = CherryPicker("origin", "xxx", []) 1284 | 1285 | with mock.patch("cherry_picker.cherry_picker.wipe_cfg_vals_from_git_cfg"): 1286 | cherry_picker.continue_cherry_pick() 1287 | 1288 | assert get_state() == WORKFLOW_STATES.CONTINUATION_FAILED 1289 | 1290 | 1291 | def test_abort_cherry_pick_invalid_state(tmp_git_repo_dir): 1292 | assert get_state() == WORKFLOW_STATES.UNSET 1293 | 1294 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1295 | cherry_picker = CherryPicker("origin", "xxx", []) 1296 | 1297 | assert get_state() == WORKFLOW_STATES.UNSET 1298 | 1299 | with pytest.raises( 1300 | ValueError, match=re.compile(r"^One can only abort a paused process.") 1301 | ): 1302 | cherry_picker.abort_cherry_pick() 1303 | 1304 | 1305 | def test_abort_cherry_pick_success( 1306 | tmp_git_repo_dir, git_branch, git_add, git_commit, git_checkout, git_cherry_pick 1307 | ): 1308 | cherry_pick_target_branches = ("3.8",) 1309 | pr_remote = "origin" 1310 | test_file = "some.file" 1311 | git_branch(f"backport-xxx-{cherry_pick_target_branches[0]}") 1312 | 1313 | tmp_git_repo_dir.join(test_file).write("some contents") 1314 | git_add(test_file) 1315 | git_commit("Add a test file") 1316 | scm_revision = get_sha1_from("HEAD") 1317 | 1318 | git_checkout(f"backport-xxx-{cherry_pick_target_branches[0]}") 1319 | tmp_git_repo_dir.join(test_file).write("some other contents") 1320 | git_add(test_file) 1321 | git_commit("Add a test file again") 1322 | 1323 | try: 1324 | git_cherry_pick(scm_revision) # simulate a conflict with pause 1325 | except subprocess.CalledProcessError: 1326 | pass 1327 | 1328 | set_state(WORKFLOW_STATES.BACKPORT_PAUSED) 1329 | 1330 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1331 | cherry_picker = CherryPicker( 1332 | pr_remote, scm_revision, cherry_pick_target_branches 1333 | ) 1334 | 1335 | with mock.patch("cherry_picker.cherry_picker.wipe_cfg_vals_from_git_cfg"): 1336 | cherry_picker.abort_cherry_pick() 1337 | 1338 | assert get_state() == WORKFLOW_STATES.REMOVED_BACKPORT_BRANCH 1339 | 1340 | 1341 | def test_cli_invoked(): 1342 | subprocess.check_call("cherry_picker --help".split()) 1343 | 1344 | 1345 | @pytest.mark.parametrize("draft_pr", (True, False)) 1346 | @mock.patch("requests.post") 1347 | @mock.patch("gidgethub.sansio.create_headers") 1348 | @mock.patch.object(CherryPicker, "username", new_callable=mock.PropertyMock) 1349 | def test_create_gh_pr_draft_states( 1350 | mock_username, mock_create_headers, mock_post, monkeypatch, draft_pr, config 1351 | ): 1352 | config["draft_pr"] = draft_pr 1353 | mock_username.return_value = "username" 1354 | monkeypatch.setenv("GH_AUTH", "True") 1355 | with mock.patch("cherry_picker.cherry_picker.validate_sha", return_value=True): 1356 | cherry_picker = CherryPicker( 1357 | "origin", "xxx", [], prefix_commit=True, config=config 1358 | ) 1359 | mock_create_headers.return_value = {"Authorization": "token gh-token"} 1360 | 1361 | mock_response = MagicMock() 1362 | mock_response.status_code = 201 1363 | mock_response.json.return_value = { 1364 | "html_url": "https://github.com/octocat/Hello-World/pull/1347", 1365 | "number": 1347, 1366 | } 1367 | mock_post.return_value = mock_response 1368 | 1369 | base_branch = "main" 1370 | head_branch = "feature-branch" 1371 | commit_message = "Commit message" 1372 | gh_auth = "gh_auth" 1373 | 1374 | cherry_picker.create_gh_pr( 1375 | base_branch, head_branch, commit_message=commit_message, gh_auth=gh_auth 1376 | ) 1377 | 1378 | mock_post.assert_called_once_with( 1379 | "https://api.github.com/repos/python/cpython/pulls", 1380 | headers={"Authorization": "token gh-token"}, 1381 | json={ 1382 | "title": "Commit message", 1383 | "body": "", 1384 | "head": "username:feature-branch", 1385 | "base": "main", 1386 | "maintainer_can_modify": True, 1387 | "draft": draft_pr, 1388 | }, 1389 | timeout=30, 1390 | ) 1391 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-vcs", 5 | "hatchling", 6 | ] 7 | 8 | [project] 9 | name = "cherry-picker" 10 | description = "Backport CPython changes from main to maintenance branches" 11 | readme = "README.md" 12 | maintainers = [ { name = "Python Core Developers", email = "core-workflow@python.org" } ] 13 | authors = [ { name = "Mariatta Wijaya", email = "mariatta@python.org" } ] 14 | requires-python = ">=3.9" 15 | classifiers = [ 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: Apache Software License", 18 | "Programming Language :: Python :: 3 :: Only", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: 3.14", 25 | ] 26 | dynamic = [ "version" ] 27 | dependencies = [ 28 | "click>=6", 29 | "gidgethub", 30 | "requests", 31 | "stamina", 32 | "tomli>=1.1; python_version<'3.11'", 33 | ] 34 | optional-dependencies.dev = [ 35 | "pytest", 36 | "pytest-cov", 37 | ] 38 | urls.Homepage = "https://github.com/python/cherry-picker" 39 | scripts.cherry_picker = "cherry_picker.cherry_picker:cherry_pick_cli" 40 | 41 | [tool.hatch.version] 42 | source = "vcs" 43 | # Change regex to match tags like "cherry-picker-v2.2.0". 44 | tag-pattern = '^cherry-picker-(?P[vV]?\d+(?:\.\d+){0,2}[^\+]*)(?:\+.*)?$' 45 | 46 | [tool.hatch.build.hooks.vcs] 47 | version-file = "cherry_picker/_version.py" 48 | 49 | [tool.hatch.version.raw-options] 50 | local_scheme = "no-local-version" 51 | 52 | [tool.ruff] 53 | fix = true 54 | lint.select = [ 55 | "C4", # flake8-comprehensions 56 | "E", # pycodestyle errors 57 | "F", # pyflakes errors 58 | "I", # isort 59 | "ICN", # flake8-import-conventions 60 | "ISC", # flake8-implicit-str-concat 61 | "LOG", # flake8-logging 62 | "PGH", # pygrep-hooks 63 | "PYI", # flake8-pyi 64 | "RUF022", # unsorted-dunder-all 65 | "RUF100", # unused noqa (yesqa) 66 | "S", # flake8-bandit 67 | "UP", # pyupgrade 68 | "W", # pycodestyle warnings 69 | "YTT", # flake8-2020 70 | ] 71 | lint.ignore = [ 72 | "S101", # Use of assert detected 73 | "S404", # subprocess module is possibly insecure 74 | "S603", # subprocess call: check for execution of untrusted input 75 | ] 76 | lint.isort.required-imports = [ "from __future__ import annotations" ] 77 | 78 | [tool.pyproject-fmt] 79 | max_supported_python = "3.14" 80 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs = dist docs build .git .eggs .tox 3 | addopts = --durations=10 -v -rxXs --doctest-modules 4 | filterwarnings = 5 | error 6 | junit_duration_report = call 7 | junit_suite_name = cherry_picker_test_suite 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4.2 4 | env_list = 5 | py{313, 312, 311, 310, 39} 6 | 7 | [testenv] 8 | extras = 9 | dev 10 | pass_env = 11 | FORCE_COLOR 12 | commands = 13 | {envpython} -m pytest \ 14 | --cov cherry_picker \ 15 | --cov-report html \ 16 | --cov-report term \ 17 | --cov-report xml \ 18 | {posargs} 19 | --------------------------------------------------------------------------------