Skip to content

Commit 8d05dd6

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. A clean pick leaves the branch and removes its worktree. A conflict keeps the worktree so it can be resolved in place. Each branch is cut from the same remote-tracking release branch analyze judged. A fork is usually behind on the release branches, so cutting from origin would build the backport on a base the analysis never saw. Nothing is pushed and no pull request is opened.
1 parent 6e077c6 commit 8d05dd6

7 files changed

Lines changed: 542 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: 72 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

@@ -19,7 +20,9 @@ passes:
1920
It also says when a fix reaches inside the validated FIPS module, which is a
2021
certification question rather than a code one.
2122

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

2427
## Prerequisites
2528

@@ -143,6 +146,57 @@ sure nobody finds out later. The same line is carried into every pull request `p
143146
opens and into the summary it posts, so it survives being read by someone who never ran
144147
`analyze`.
145148

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

148202
### Model settings
@@ -241,7 +295,8 @@ util/backport/
241295
├── src/
242296
│ ├── main.py # argument parsing
243297
│ ├── commands/
244-
│ │ └── analyze.py # the analyze command
298+
│ │ ├── analyze.py # the analyze command
299+
│ │ └── apply.py # the apply command
245300
│ ├── engine/
246301
│ │ ├── inspect_fix.py # which lines the fix deletes, who wrote them
247302
│ │ ├── discover_branches.py # which release branches to check
@@ -258,6 +313,7 @@ util/backport/
258313
│ ├── fixes.txt # 39 real fixes to replay
259314
│ └── answer_key.txt # which branches each one should flag
260315
└── .backport-runs/ # the last analyze result, not checked in
316+
.backport-worktrees/ # where a conflicted pick waits, not checked in
261317
```
262318

263319
## Testing
@@ -384,6 +440,18 @@ of its own, so analyze what it brought in instead.
384440
util/backport/backport analyze --commit <sha>^..<sha>
385441
```
386442

443+
### No saved analyze run
444+
445+
`apply` acts on what `analyze` decided, so `analyze` has to have run first:
446+
447+
```bash
448+
util/backport/backport analyze
449+
```
450+
451+
The same error appears if the run names a fix this checkout no longer has, which
452+
happens when a range was analyzed and git has since collected the squashed commit.
453+
Re-running `analyze` fixes both.
454+
387455
### Wrong or empty results from a subdirectory
388456

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

224+
# What every local branch holding a backport is named after. Kept here so the code that
225+
# builds the name and the code that later has to recognize it cannot drift apart
226+
BACKPORT_BRANCH_PREFIX = "backport-"
227+
224228

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

0 commit comments

Comments
 (0)