Skip to content

Commit 17cfd10

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 8fbd394 commit 17cfd10

6 files changed

Lines changed: 520 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

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

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

128181
### Model settings
@@ -196,7 +249,8 @@ util/backport/
196249
├── src/
197250
│ ├── main.py # argument parsing
198251
│ ├── commands/
199-
│ │ └── analyze.py # the analyze command
252+
│ │ ├── analyze.py # the analyze command
253+
│ │ └── apply.py # the apply command
200254
│ ├── engine/
201255
│ │ ├── inspect_fix.py # which lines the fix deletes, who wrote them
202256
│ │ ├── discover_branches.py # which release branches to check
@@ -213,6 +267,7 @@ util/backport/
213267
│ ├── fixes.txt # 39 real fixes to replay
214268
│ └── answer_key.txt # which branches each one should flag
215269
└── .backport-runs/ # the last analyze result, not checked in
270+
.backport-worktrees/ # where a conflicted pick waits, not checked in
216271
```
217272

218273
## Testing
@@ -338,6 +393,18 @@ of its own, so analyze what it brought in instead.
338393
util/backport/backport analyze --commit <sha>^..<sha>
339394
```
340395

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

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

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,68 @@ def resolve_on_branch(
379379
if older != file_path and file_on_branch(branch_ref, older):
380380
return older
381381
return None
382+
383+
384+
# --- Backporting ---
385+
386+
# Cherry-picks happen in a worktree under here, never in the tree you are sitting in
387+
WORKTREE_ROOT = TOOL_ROOT / ".backport-worktrees"
388+
389+
390+
def commit_exists(sha: str) -> bool:
391+
"""True when this checkout still has that commit"""
392+
return git("cat-file", "-e", f"{sha}^{{commit}}", check=False).returncode == 0
393+
394+
395+
def branch_exists(name: str) -> bool:
396+
"""True when that local branch already exists"""
397+
ref = f"refs/heads/{name}"
398+
return git("show-ref", "--verify", "--quiet", ref, check=False).returncode == 0
399+
400+
401+
def add_worktree(path, branch: str, start_point: str) -> None:
402+
"""
403+
Checks start_point out at path on a new branch
404+
A worktree is used so apply never moves the branch you have checked out, and an
405+
unfinished cherry-pick can never strand your own working tree mid-merge
406+
"""
407+
path.parent.mkdir(parents=True, exist_ok=True)
408+
git("worktree", "add", "-q", "-b", branch, str(path), start_point)
409+
410+
411+
def remove_worktree(path) -> None:
412+
"""Drops the worktree but keeps the branch it built"""
413+
git("worktree", "remove", "--force", str(path), check=False)
414+
415+
416+
def cherry_pick(path, sha: str) -> Tuple[bool, List[str]]:
417+
"""
418+
Cherry-picks sha in the worktree at path
419+
Returns (applied, conflicted files). The user's own name lands on the commit
420+
because git is left to read their config, so the result is theirs to push
421+
422+
-x is what writes the "cherry picked from commit" line into the message, which is
423+
one of the three signals analyze uses to spot a branch that already has the fix.
424+
Without it the tool could not recognise its own backports on the next run
425+
"""
426+
picked = git("-C", str(path), "cherry-pick", "-x", sha, check=False)
427+
if picked.returncode == 0:
428+
return True, []
429+
unmerged = git(
430+
"-C", str(path), "diff", "--name-only", "--diff-filter=U", check=False
431+
)
432+
return False, [f for f in unmerged.stdout.splitlines() if f.strip()]
433+
434+
435+
def cherry_pick_was_empty(path) -> bool:
436+
"""
437+
True when the cherry-pick stopped because the change is already there
438+
git calls this an empty commit, which means the branch did not need the fix
439+
"""
440+
state = git("-C", str(path), "status", "--porcelain", check=False)
441+
return not state.stdout.strip()
442+
443+
444+
def abort_cherry_pick(path) -> None:
445+
"""Backs a stopped cherry-pick out, leaving the worktree on its branch"""
446+
git("-C", str(path), "cherry-pick", "--abort", check=False)

0 commit comments

Comments
 (0)