Skip to content

Commit 93dad97

Browse files
committed
Add the apply command to cherry-pick onto affected branches
Reads the run analyze saved and cherry-picks the fix onto one local branch per affected branch, named backport-<release branch>-<fix>. Branches analyze could not settle are left out, since picking onto one of those would be a guess Each pick runs in its own worktree, so the branch you have checked out never moves and a half finished cherry-pick cannot strand your own working tree mid merge. A clean pick leaves the branch and removes its worktree, a conflict keeps the worktree so it can be resolved in place Nothing is pushed and no pull request is opened
1 parent 32ce7c3 commit 93dad97

7 files changed

Lines changed: 533 additions & 5 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ symbols.txt
4848
awslcTestTmpFile*
4949
/pages-output
5050
util/backport/.backport-runs/
51+
util/backport/.backport-worktrees/

util/backport/README.md

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
# AWS-LC Backport Analysis
1+
# AWS-LC Backport
22

3-
Works out which supported release branches still need a fix, before it merges.
3+
Works out which supported release branches still need a fix, before it merges, and
4+
cherry-picks it onto the ones that do.
45

56
## What This Tool Does
67

@@ -16,7 +17,9 @@ passes:
1617
review, but a no-answer always leaves the branch flagged, so it can never hide a
1718
needed backport.
1819

19-
Nothing is cherry-picked, pushed, or committed. The tool only reports.
20+
`apply` then cherry-picks the fix onto one local branch per affected branch.
21+
22+
Nothing is pushed and no pull request is opened. The branches are yours to review.
2023

2124
## Prerequisites
2225

@@ -124,6 +127,56 @@ Anything genuinely unclear becomes `AFFECTED` rather than `not affected`. A wron
124127
"not affected" means a missed security backport, so the tool always errs toward
125128
flagging.
126129

130+
### Cherry-pick onto the affected branches
131+
132+
```bash
133+
util/backport/backport apply
134+
```
135+
136+
Reads the last `analyze` run and cherry-picks the fix onto one local branch per
137+
affected branch, named `backport-<release branch>-<fix>`. Branches `analyze` could not
138+
settle are left out, since picking onto one of those would be a guess. `--branch` does
139+
a single branch, including one that was cleared, and `--yes` skips the confirm.
140+
141+
Each pick happens in its own worktree under `.backport-worktrees/`, so the branch you
142+
have checked out never moves and a half-finished cherry-pick can never strand your own
143+
working tree mid-merge.
144+
145+
**Example Output:**
146+
147+
```
148+
Fix ac3aee3104, analyzed 2026-08-04 14:36:20
149+
Backporting onto 7 branch(es): fips-2026-06-26-snapshot, fips-2025-09-12-lts, ...
150+
Create these local branches? [Y/N] y
151+
fips-2026-06-26-snapshot: applied, on backport-fips-2026-06-26-snapshot-ac3aee3104
152+
fips-2025-09-12-lts: applied, on backport-fips-2025-09-12-lts-ac3aee3104
153+
fips-2022-11-02: CONFLICT in 4 file(s)
154+
crypto/dh_extra/dh_test.cc
155+
crypto/fipsmodule/dh/check.c
156+
resolve in util/backport/.backport-worktrees/backport-fips-2022-11-02-ac3aee3104
157+
158+
2 of 7 applied cleanly
159+
Resolve each conflict in the worktree named above, then 'git cherry-pick --continue' there.
160+
Nothing was pushed. Review each branch before you open a pull request.
161+
```
162+
163+
A clean pick leaves just the branch and removes its worktree. A conflict keeps the
164+
worktree, stopped mid-cherry-pick, so you can resolve it in place. Conflicts are
165+
normal on the older branches, where the surrounding code has moved on.
166+
167+
**Results:**
168+
169+
| Result | Meaning |
170+
| --- | --- |
171+
| `applied` | cherry-picked cleanly, the branch is left behind and its worktree removed |
172+
| `CONFLICT` | the worktree is kept, stopped mid-cherry-pick, for you to resolve |
173+
| `skipped` | that backport branch already exists, so nothing was touched |
174+
| `nothing to do` | the change is already on the branch, so the pick came out empty |
175+
176+
`skipped` is what makes a second run safe: re-running after resolving one conflict
177+
leaves the branches you already have alone. The command exits non-zero if any branch
178+
conflicted, so a script can tell whether anything needs a human.
179+
127180
## Configuration
128181

129182
### Model settings
@@ -195,7 +248,8 @@ util/backport/
195248
├── src/
196249
│ ├── main.py # argument parsing
197250
│ ├── commands/
198-
│ │ └── analyze.py # the analyze command
251+
│ │ ├── analyze.py # the analyze command
252+
│ │ └── apply.py # the apply command
199253
│ ├── engine/
200254
│ │ ├── inspect_fix.py # which lines the fix deletes, who wrote them
201255
│ │ ├── discover_branches.py # which release branches to check
@@ -212,6 +266,7 @@ util/backport/
212266
│ ├── fixes.txt # 39 real fixes to replay
213267
│ └── answer_key.txt # which branches each one should flag
214268
└── .backport-runs/ # the last analyze result, not checked in
269+
.backport-worktrees/ # where a conflicted pick waits, not checked in
215270
```
216271

217272
## Testing
@@ -337,6 +392,18 @@ of its own, so analyze what it brought in instead.
337392
util/backport/backport analyze --commit <sha>^..<sha>
338393
```
339394

395+
### No saved analyze run
396+
397+
`apply` acts on what `analyze` decided, so `analyze` has to have run first:
398+
399+
```bash
400+
util/backport/backport analyze
401+
```
402+
403+
The same error appears if the run names a fix this checkout no longer has, which
404+
happens when a range was analyzed and git has since collected the squashed commit.
405+
Re-running `analyze` fixes both.
406+
340407
### Wrong or empty results from a subdirectory
341408

342409
Should not happen. The tool pins itself to the checkout it lives in rather than
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0 OR ISC
3+
4+
"""
5+
The apply command: cherry-picks the fix onto one local branch per affected branch
6+
Nothing is pushed and no pull request is opened, the branches are yours to review
7+
"""
8+
9+
from util.config import (
10+
AFFECTED,
11+
BACKPORT_BRANCH_PREFIX,
12+
RUN_FILE,
13+
UNSURE,
14+
BackportError,
15+
)
16+
from util.git import (
17+
WORKTREE_ROOT,
18+
abort_cherry_pick,
19+
add_worktree,
20+
branch_exists,
21+
cherry_pick,
22+
cherry_pick_was_empty,
23+
commit_exists,
24+
remove_worktree,
25+
)
26+
from util.render import ask_yn
27+
28+
import json
29+
from typing import Dict, List, Optional, Tuple
30+
31+
# How one branch turned out
32+
APPLIED = "applied"
33+
CONFLICT = "conflict"
34+
ALREADY_THERE = "already there"
35+
BRANCH_EXISTS = "branch exists"
36+
37+
38+
def load_run() -> dict:
39+
"""
40+
The run analyze saved, as {generated_at, fix, base, branches, verdicts}
41+
Written by save_run in util/config.py, where the fields are spelled out
42+
43+
Raises rather than returning a partial answer, in all three cases where acting on
44+
the run would mean guessing: there is no run, the file cannot be read, or the fix
45+
it names is no longer in this checkout
46+
"""
47+
try:
48+
run = json.loads(RUN_FILE.read_text(encoding="utf-8"))
49+
except FileNotFoundError:
50+
raise BackportError(
51+
"no saved analyze run to apply.\n Run 'backport analyze' first."
52+
)
53+
except json.JSONDecodeError as exc:
54+
raise BackportError(f"{RUN_FILE} is not valid JSON: {exc}")
55+
56+
# These two are the whole input: which commit to pick, and onto which branches
57+
for key in ("fix", "verdicts"):
58+
if key not in run:
59+
raise BackportError(f"{RUN_FILE} has no '{key}'. Re-run analyze.")
60+
61+
# Analyzing a range squashes it into a commit nothing points at, so git can collect
62+
# it between analyze and apply
63+
if not commit_exists(run["fix"]):
64+
raise BackportError(
65+
f"the analyzed fix {run['fix'][:10]} is not in this checkout any more.\n"
66+
" Re-run analyze."
67+
)
68+
return run
69+
70+
71+
def backport_branch_name(fix: str, release: str) -> str:
72+
"""What the local branch holding a backport is called"""
73+
return f"{BACKPORT_BRANCH_PREFIX}{release}-{fix[:10]}"
74+
75+
76+
def branches_to_backport(verdicts: Dict[str, str], only: Optional[str]) -> List[str]:
77+
"""
78+
Which release branches to cherry-pick onto
79+
Returns the affected ones, or just the named branch. Unsure branches are left
80+
out: analyze could not settle them, so a cherry-pick would be a guess
81+
"""
82+
if only:
83+
if only not in verdicts:
84+
known = ", ".join(sorted(verdicts))
85+
raise BackportError(f"'{only}' was not in the analyze run.\n Had: {known}")
86+
return [only]
87+
return [b for b, state in verdicts.items() if state == AFFECTED]
88+
89+
90+
def cherry_pick_onto(fix: str, release: str) -> Tuple[str, List[str]]:
91+
"""
92+
Cherry-picks the fix onto one release branch, in a worktree of its own
93+
Returns (outcome, conflicting files) where outcome is one of the four above
94+
95+
The worktree is what makes this safe to run while you are working: your own
96+
checkout never moves. It is removed once the pick is settled either way, and kept
97+
only when there is a conflict, because that is where you resolve it
98+
"""
99+
local_branch = backport_branch_name(fix, release)
100+
if branch_exists(local_branch):
101+
return BRANCH_EXISTS, []
102+
103+
worktree = WORKTREE_ROOT / local_branch
104+
add_worktree(worktree, local_branch, f"origin/{release}")
105+
picked, conflicts = cherry_pick(worktree, fix)
106+
107+
if picked:
108+
remove_worktree(worktree)
109+
return APPLIED, []
110+
if not conflicts and cherry_pick_was_empty(worktree):
111+
# The pick came out empty, so this branch already has the change
112+
abort_cherry_pick(worktree)
113+
remove_worktree(worktree)
114+
return ALREADY_THERE, []
115+
return CONFLICT, conflicts
116+
117+
118+
def cmd_apply(args) -> int:
119+
"""
120+
Cherry-picks the analyzed fix onto every affected branch
121+
Returns 0 when every branch applied cleanly or was skipped, 1 when any conflicted
122+
"""
123+
run = load_run()
124+
fix, verdicts = run["fix"], run["verdicts"]
125+
releases = branches_to_backport(verdicts, args.branch)
126+
127+
if not releases:
128+
print("No affected branches in the last analyze run, nothing to apply.")
129+
return 0
130+
131+
unsure = [b for b, state in verdicts.items() if state == UNSURE]
132+
print(f"Fix {fix[:10]}, analyzed {run.get('generated_at', 'at an unknown time')}")
133+
print(f"Backporting onto {len(releases)} branch(es): {', '.join(releases)}")
134+
if unsure:
135+
print(
136+
f"Leaving out {len(unsure)} branch(es) analyze could not settle: "
137+
f"{', '.join(unsure)}"
138+
)
139+
if not args.yes and not ask_yn("Create these local branches?"):
140+
print("Aborted. Nothing was created.")
141+
return 0
142+
143+
outcomes = {}
144+
for release in releases:
145+
outcome, conflicts = cherry_pick_onto(fix, release)
146+
outcomes[release] = (outcome, conflicts)
147+
local_branch = backport_branch_name(fix, release)
148+
if outcome == APPLIED:
149+
print(f" {release}: applied, on {local_branch}")
150+
elif outcome == BRANCH_EXISTS:
151+
print(f" {release}: skipped, {local_branch} already exists")
152+
elif outcome == ALREADY_THERE:
153+
print(f" {release}: nothing to do, the change is already there")
154+
else:
155+
print(f" {release}: CONFLICT in {len(conflicts)} file(s)")
156+
for name in conflicts:
157+
print(f" {name}")
158+
print(f" resolve in {WORKTREE_ROOT / local_branch}")
159+
160+
conflicted = [b for b, (o, _) in outcomes.items() if o == CONFLICT]
161+
print()
162+
print(f"{len(outcomes) - len(conflicted)} of {len(outcomes)} applied cleanly")
163+
if conflicted:
164+
print(
165+
"Resolve each conflict in the worktree named above, then "
166+
"'git cherry-pick --continue' there."
167+
)
168+
print("Nothing was pushed. Review each branch before you open a pull request.")
169+
return 1 if conflicted else 0

util/backport/src/main.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
from commands.analyze import cmd_analyze
10+
from commands.apply import cmd_apply
1011
from util.config import BackportError
1112

1213
import argparse
@@ -33,17 +34,37 @@ def add_analyze(subparsers) -> None:
3334
p.set_defaults(func=cmd_analyze)
3435

3536

37+
def add_apply(subparsers) -> None:
38+
"""
39+
Cherry-picks the analyzed fix onto a local branch per affected release branch
40+
Reads the run analyze saved, so analyze has to have run first
41+
"""
42+
p = subparsers.add_parser(
43+
"apply", help="cherry-picks the fix onto a branch per affected branch"
44+
)
45+
p.add_argument(
46+
"--branch", help="only this release branch, instead of every affected one"
47+
)
48+
p.add_argument(
49+
"--yes",
50+
action="store_true",
51+
help="Skips the confirm. Useful for test scripts",
52+
)
53+
p.set_defaults(func=cmd_apply)
54+
55+
3656
def build_parser() -> argparse.ArgumentParser:
3757
"""
3858
Build parser for args
39-
Returns the parser, with the analyze subcommand on it
59+
Returns the parser, with the analyze and apply subcommands on it
4060
"""
4161
ap = argparse.ArgumentParser(
4262
prog="backport",
4363
description="Local CLI tool for backport analysis and automation",
4464
)
4565
subparsers = ap.add_subparsers(dest="cmd", required=True)
4666
add_analyze(subparsers)
67+
add_apply(subparsers)
4768
return ap
4869

4970

util/backport/src/util/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ def is_test_or_generated_file(f: str) -> bool:
174174
# release branches, or missing them entirely, so guessing origin is not safe
175175
RELEASE_REMOTE = os.environ.get("BACKPORT_REMOTE", "").strip()
176176

177+
# What every local branch holding a backport is named after. Kept here because two
178+
# places rely on it: apply builds the name, and publish refuses to push anything else
179+
BACKPORT_BRANCH_PREFIX = "backport-"
180+
177181

178182
# --- The Saved Run ---
179183
# analyze writes its result here so apply can pick it up. Kept next to the tool,

0 commit comments

Comments
 (0)