Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ treemap*.png
# Claude Code PR review artifacts
.agint-review/

# `ddev release port-commit` worktrees
.worktrees/

# Ignore any metadata file except root json in the downloader
datadog_checks_downloader/datadog_checks/downloader/data/repo/targets/*
!datadog_checks_downloader/datadog_checks/downloader/data/repo/targets/.gitignore
Expand Down
1 change: 1 addition & 0 deletions ddev/changelog.d/23686.added
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `ddev release port-commit` command to backport a commit to a target branch.
2 changes: 2 additions & 0 deletions ddev/src/ddev/cli/release/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from ddev.cli.release.branch import branch
from ddev.cli.release.changelog import changelog
from ddev.cli.release.list_versions import list_versions
from ddev.cli.release.port_commit import port_commit
from ddev.cli.release.show import show
from ddev.cli.release.stats import stats

Expand All @@ -28,6 +29,7 @@ def release():
release.add_command(changelog)
release.add_command(list_versions)
release.add_command(make)
release.add_command(port_commit)
release.add_command(show)
release.add_command(stats)
release.add_command(tag)
Expand Down
107 changes: 107 additions & 0 deletions ddev/src/ddev/cli/release/port_commit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# (C) Datadog, Inc. 2026-present
# All rights reserved
# Licensed under a 3-clause BSD style license (see LICENSE)
from __future__ import annotations

from typing import TYPE_CHECKING

import click

if TYPE_CHECKING:
from ddev.cli.application import Application


@click.command(name='port-commit', short_help='Backport a commit onto a target branch')
@click.pass_obj
@click.argument('commit_hash', required=False)
@click.option('-t', '--target-branch', default='master', show_default=True, help='Target branch to port to.')
@click.option('-p', '--branch-prefix', default='port', show_default=True, help='Branch name prefix.')
@click.option('-s', '--branch-suffix', default=None, help='Branch name suffix. Defaults to `to-<target-branch>`.')
@click.option(
'-l',
'--pr-labels',
default='qa/skip-qa',
show_default=True,
help='Comma-separated PR labels.',
)
@click.option('--no-pr', is_flag=True, default=False, help="Don't create a pull request.")
@click.option('--draft', is_flag=True, default=False, help='Open the PR as a draft.')
@click.option('--verify', is_flag=True, default=False, help='Run commit hooks (skipped by default).')
@click.option('--dry-run', is_flag=True, default=False, help='Print every step instead of executing it.')
def port_commit(
app: Application,
commit_hash: str | None,
target_branch: str,
branch_prefix: str,
branch_suffix: str | None,
pr_labels: str,
no_pr: bool,
draft: bool,
verify: bool,
dry_run: bool,
) -> None:
"""
Backport a commit onto a target branch.

Cherry-picks COMMIT_HASH onto `--target-branch` (default `master`) on a new branch named
`<github-user>/<prefix>-<sha[:10]>-<suffix>`, preserving `.in-toto` files from the target
branch so package signatures stay intact. Pushes the branch and, unless `--no-pr` is set,
opens a pull request titled `[Backport] <subject>` and labeled with `--pr-labels`.

If COMMIT_HASH is omitted, the current HEAD commit is used after confirmation.

The GitHub user for the branch prefix is taken from `ddev config` (`github.user`) or the
`DD_GITHUB_USER` / `GITHUB_USER` / `GITHUB_ACTOR` environment variables.
"""
from ddev.cli.release.port_commit_workflow import (
PortStepError,
build_port_steps,
display_completion_summary,
resolve_port_plan,
)

plan = resolve_port_plan(
app,
commit_hash=commit_hash,
target_branch=target_branch,
branch_prefix=branch_prefix,
branch_suffix=branch_suffix,
pr_labels=pr_labels,
no_pr=no_pr,
draft=draft,
verify=verify,
dry_run=dry_run,
)
bundle = build_port_steps(app, plan)

success = False
error_msg: str | None = None
try:
for step in bundle.steps:
step.run()
success = True
except PortStepError as e:
error_msg = str(e)
finally:
# If the PR was created before the failure (e.g. labeling failed afterwards), the worktree
# holds no recoverable state — the work is pushed and the PR exists on GitHub. Suppress the
# warning in that case to avoid a misleading "inspect the worktree" message.
pr_already_created = bundle.pr_step is not None and bundle.pr_step.pr_url is not None
if not success and not plan.dry_run and not pr_already_created:
app.display_warning(f'Worktree left at `{plan.worktree_path}` for inspection.')

if error_msg is not None:
app.abort(error_msg)

try:
bundle.teardown.run()
except PortStepError as e:
app.display_warning(f'Could not remove worktree at `{plan.worktree_path}`: {e}')
app.display_warning(f'Run `git worktree remove --force {plan.worktree_path}` to clean it up manually.')

if plan.dry_run:
app.display_success('Dry run complete.')
return

pr_url = bundle.pr_step.pr_url if bundle.pr_step is not None else None
display_completion_summary(app, plan, pr_url=pr_url)
Loading
Loading