88# tools --(TOOLS_REV in pkgs/Pkgfile)--------> pkgs
99# pkgs --(PKGS in talos/Makefile)-----------> talos
1010#
11- # For each hop, finds the first dependent-repo commit whose pin resolves to a
12- # ref that has the fix commit as an ancestor.
11+ # Walks origin/main AND every origin/release-* branch, since fixes are often
12+ # backported as a separate commit with the same subject. Equivalence on a
13+ # backport branch is determined by direct ancestry first, then by commit
14+ # subject (matches conventional cherry-pick workflow).
1315#
1416# Usage:
1517# trace-fix.sh \
1820# --pkgs /path/to/pkgs \
1921# --talos /path/to/talos \
2022# --from <toolchain|tools|pkgs> \
21- # --commit <sha>
23+ # --commit <sha> \
24+ # [--branch <name>]
2225#
23- # Only the repos needed for the chain from --from down to talos must be
24- # supplied. All supplied repos are fetched from origin/main before queries.
25- # Working trees are not modified.
26+ # --branch limits the trace to a single branch (e.g. "main" or "release-1.13").
27+ # Default: walk main + every origin/release-* branch where the fix is present.
28+ # All supplied repos are fetched from origin before queries. Working trees are
29+ # not modified.
2630
2731set -euo pipefail
2832
2933usage () {
30- { sed -nE ' s/^# ?//p' " $0 " | head -30 ; } >&2 || true
34+ { sed -nE ' s/^# ?//p' " $0 " | head -33 ; } >&2 || true
3135 exit 1
3236}
3337
@@ -37,6 +41,7 @@ PKGS_PATH=""
3741TALOS_PATH=" "
3842FROM=" "
3943COMMIT=" "
44+ BRANCH_FILTER=" "
4045
4146while [ $# -gt 0 ]; do
4247 case " $1 " in
@@ -46,6 +51,7 @@ while [ $# -gt 0 ]; do
4651 --talos) TALOS_PATH=" $2 " ; shift 2;;
4752 --from) FROM=" $2 " ; shift 2;;
4853 --commit) COMMIT=" $2 " ; shift 2;;
54+ --branch) BRANCH_FILTER=" $2 " ; shift 2;;
4955 -h|--help) usage;;
5056 * ) echo " unknown arg: $1 " >&2 ; usage;;
5157 esac
@@ -87,28 +93,66 @@ for r in $CHAIN; do
8793 || { echo " $p is not a git repo" >&2 ; exit 1; }
8894done
8995
90- # Fetch origin/main + tags for every repo. Queries run against origin/main so
91- # local branch state is irrelevant and never modified.
96+ # Fetch origin's `main` plus every `release-*` branch (and all tags) for every
97+ # repo. Queries below run against refs/remotes/origin/{main,release-*} so local
98+ # branch state is irrelevant. Branches outside that set are intentionally not
99+ # fetched — `--branch <name>` is expected to target one of these refs.
92100sync_repo () {
93101 name=" $1 "
94102 path=$( repo_path " $name " )
95103 echo " >> fetch $name ($path )" >&2
96- err=$( git -C " $path " fetch --tags --quiet origin main 2>&1 ) || {
97- echo " WARN: fetch failed for $name (using last pulled origin/main ; results may be stale):" >&2
104+ err=$( git -C " $path " fetch --tags --quiet origin ' +refs/heads/ main:refs/remotes/origin/main ' ' +refs/heads/release-*:refs/remotes/origin/release-* ' 2>&1 ) || {
105+ echo " WARN: fetch failed for $name (using last pulled refs ; results may be stale):" >&2
98106 printf ' %s\n' " $err " | sed ' s/^/ /' >&2
99107 }
100- local_branch=$( git -C " $path " rev-parse --abbrev-ref HEAD 2> /dev/null || echo " DETACHED" )
101- if [ " $local_branch " != " main" ]; then
102- echo " note: $name working tree on '$local_branch ' (queries use origin/main)" >&2
103- fi
104108}
105109
106110for r in $CHAIN ; do
107111 sync_repo " $r "
108112done
109113
110- # Resolve a pin string (e.g. "v1.14.0-alpha.0-2-gd6c7ac5" or "v1.14.0-alpha.0")
111- # to a full SHA in the upstream repo. Empty on failure.
114+ # List branches to check in source repo: main + every origin/release-*
115+ # (sorted), respecting --branch filter.
116+ list_source_branches () {
117+ src=" $1 "
118+ {
119+ git -C " $src " show-ref --verify --quiet refs/remotes/origin/main && echo " main"
120+ git -C " $src " for-each-ref --format=' %(refname:short)' ' refs/remotes/origin/release-*' \
121+ | sed ' s|^origin/||' \
122+ | sort -V
123+ }
124+ }
125+
126+ branch_exists () {
127+ repo=" $1 " ; branch=" $2 "
128+ git -C " $repo " show-ref --verify --quiet " refs/remotes/origin/$branch "
129+ }
130+
131+ # Find equivalent of <target> on <branch> in <repo>.
132+ # 1. Direct ancestry: target itself.
133+ # 2. Exact-subject match: first commit on branch whose %s equals target's %s.
134+ # (Walks `git log --format='%H<TAB>%s'` rather than using `--grep`, which
135+ # matches the whole message and would surface unrelated commits with a
136+ # similar substring or body line. Tab is the separator because BSD awk
137+ # doesn't accept NUL as FS; commit subjects never contain tabs in
138+ # practice.)
139+ # Echoes full sha on success.
140+ equivalent_on_branch () {
141+ repo=" $1 " ; branch=" $2 " ; target=" $3 "
142+ if git -C " $repo " merge-base --is-ancestor " $target " " origin/$branch " 2> /dev/null; then
143+ echo " $target "
144+ return 0
145+ fi
146+ subj=$( git -C " $repo " log -1 --format=' %s' " $target " )
147+ eq=$(
148+ git -C " $repo " log --format=' %H%x09%s' " origin/$branch " 2> /dev/null \
149+ | awk -v subj=" $subj " -F ' \t' ' $2 == subj { print $1; exit }'
150+ )
151+ [ -n " $eq " ] && { echo " $eq " ; return 0; }
152+ return 1
153+ }
154+
155+ # Resolve a pin string to a full SHA in the upstream repo. Empty on failure.
112156resolve_ref () {
113157 repo=" $1 " ; ref=" $2 "
114158 suffix=$( printf ' %s\n' " $ref " | sed -nE ' s/.*-g([0-9a-fA-F]+)$/\1/p' )
@@ -119,25 +163,24 @@ resolve_ref() {
119163 fi
120164}
121165
122- # Walk commits on origin/main that touched <file>, oldest first, emitting
123- # "<sha> <pin-value>" lines. <sed_extract> is a sed -nE script that prints the
124- # pin value when applied to the file content.
166+ # Walk commits on origin/<branch> that touched <file>, oldest first.
125167pin_history () {
126- repo=" $1 " ; file =" $2 " ; sed_extract =" $3 "
127- shas=$( git -C " $repo " log --reverse --format=' %H' origin/main -- " $file " )
168+ repo=" $1 " ; branch =" $2 " ; file =" $3 " ; sed_extract= " $4 "
169+ shas=$( git -C " $repo " log --reverse --format=' %H' " origin/$branch " -- " $file " )
128170 for sha in $shas ; do
129- val=$( git -C " $repo " show " $sha :$file " 2> /dev/null | sed -nE " $sed_extract " | head -1 || true)
171+ val=$( git -C " $repo " show " $sha :$file " 2> /dev/null \
172+ | { sed -nE " $sed_extract " || true ; } \
173+ | { head -1 || true ; })
130174 [ -n " $val " ] && echo " $sha $val "
131175 done
132176}
133177
134- # Given dependent repo + pin location + upstream repo + target commit, find
135- # the first dependent-repo commit whose pin resolves to a ref that has
136- # <target> as an ancestor. Echoes "<dep-sha> <pin-value>" on success.
178+ # Find first commit on origin/<branch> in <dep_repo> whose pin resolves to a
179+ # ref that has <target> as an ancestor in <up_repo>. Echoes "<sha> <pin>".
137180find_first_containing () {
138- dep_repo=" $1 " ; dep_file =" $2 " ; sed_extract =" $3 " ; up_repo =" $4 " ; target =" $5 "
181+ dep_repo=" $1 " ; branch =" $2 " ; dep_file =" $3 " ; sed_extract =" $4 " ; up_repo =" $5 " ; target= " $6 "
139182 tmp=$( mktemp)
140- pin_history " $dep_repo " " $dep_file " " $sed_extract " > " $tmp "
183+ pin_history " $dep_repo " " $branch " " $ dep_file" " $sed_extract " > " $tmp "
141184 prev_val=" "
142185 rc=1
143186 while read -r sha val; do
@@ -155,7 +198,6 @@ find_first_containing() {
155198 return $rc
156199}
157200
158- # sed extractions for each pin location.
159201SED_TOOLS_PIN=' s|^[[:space:]]*TOOLCHAIN_IMAGE:[[:space:]]*ghcr\.io/siderolabs/toolchain:([^[:space:]]+).*$|\1|p'
160202SED_PKGS_PIN=' s|^[[:space:]]*TOOLS_REV:[[:space:]]*([^[:space:]]+).*$|\1|p'
161203SED_TALOS_PIN=' s|^PKGS[[:space:]]*\?=[[:space:]]*([^[:space:]]+).*$|\1|p'
@@ -174,62 +216,111 @@ print_commit_block() {
174216 return 0
175217}
176218
177- # Resolve full SHA of source commit in source repo.
178- SRC_REPO=" $FROM "
179- SRC_PATH=$( repo_path " $SRC_REPO " )
180- if ! SRC_SHA=$( git -C " $SRC_PATH " rev-parse " $COMMIT " 2> /dev/null) ; then
181- echo " commit $COMMIT not found in $SRC_REPO at $SRC_PATH " >&2
182- exit 1
183- fi
219+ # Trace one chain on a single branch. Args: branch, source-sha-on-branch.
220+ trace_chain () {
221+ branch=" $1 " ; src_sha=" $2 "
184222
185- print_commit_block " Source ($SRC_REPO )" " $SRC_REPO " " $SRC_SHA "
223+ echo
224+ echo " ==========================================================="
225+ echo " branch: $branch "
226+ echo " ==========================================================="
186227
187- # Walk the chain. CARRY_REPO/CARRY_SHA is the upstream commit we're looking
188- # for in the next-down repo's pin.
189- CARRY_REPO=" $SRC_REPO "
190- CARRY_SHA=" $SRC_SHA "
228+ print_commit_block " Source ($SRC_REPO @ $branch )" " $SRC_REPO " " $src_sha "
191229
192- set -- $CHAIN
193- shift # drop source repo; iterate dependents
194- for DEP in " $@ " ; do
195- DEP_PATH=$( repo_path " $DEP " )
230+ CARRY_REPO=" $SRC_REPO "
231+ CARRY_SHA=" $src_sha "
196232
197- case " $DEP " in
198- tools) DEP_FILE=" Pkgfile" ; SED=" $SED_TOOLS_PIN " ;;
199- pkgs) DEP_FILE=" Pkgfile" ; SED=" $SED_PKGS_PIN " ;;
200- talos) DEP_FILE=" Makefile" ; SED=" $SED_TALOS_PIN " ;;
201- esac
233+ set -- $CHAIN
234+ shift
235+ for DEP in " $@ " ; do
236+ DEP_PATH=$( repo_path " $DEP " )
202237
203- CARRY_PATH=$( repo_path " $CARRY_REPO " )
238+ if ! branch_exists " $DEP_PATH " " $branch " ; then
239+ echo
240+ echo " $DEP : no origin/$branch branch — chain stops here."
241+ return 0
242+ fi
204243
205- if ! result=$( find_first_containing " $DEP_PATH " " $DEP_FILE " " $SED " " $CARRY_PATH " " $CARRY_SHA " ) ; then
206- short=$( git -C " $CARRY_PATH " rev-parse --short " $CARRY_SHA " )
207- echo >&2
208- echo " $DEP : no pin on origin/main yet contains $CARRY_REPO commit $short ." >&2
209- exit 2
210- fi
244+ case " $DEP " in
245+ tools) DEP_FILE=" Pkgfile" ; SED=" $SED_TOOLS_PIN " ;;
246+ pkgs) DEP_FILE=" Pkgfile" ; SED=" $SED_PKGS_PIN " ;;
247+ talos) DEP_FILE=" Makefile" ; SED=" $SED_TALOS_PIN " ;;
248+ esac
249+
250+ CARRY_PATH=$( repo_path " $CARRY_REPO " )
251+
252+ if ! result=$( find_first_containing " $DEP_PATH " " $branch " " $DEP_FILE " " $SED " " $CARRY_PATH " " $CARRY_SHA " ) ; then
253+ short=$( git -C " $CARRY_PATH " rev-parse --short " $CARRY_SHA " )
254+ echo
255+ echo " $DEP @ $branch : no pin yet contains $CARRY_REPO commit $short ."
256+ return 0
257+ fi
211258
212- DEP_SHA=$( echo " $result " | awk ' {print $1}' )
213- PIN_VAL=$( echo " $result " | awk ' {print $2}' )
214-
215- EXTRA=" "
216- if [ " $DEP " = " talos" ]; then
217- DESC=$( git -C " $DEP_PATH " describe --tags " $DEP_SHA " 2> /dev/null || true)
218- CONTAIN=$( git -C " $DEP_PATH " tag --contains " $DEP_SHA " 2> /dev/null \
219- | { paste -sd ' ,' - || true ; } \
220- | sed ' s/,/, /g' )
221- REL=$( git -C " $DEP_PATH " describe --contains " $DEP_SHA " 2> /dev/null || true)
222- N_BACK=$( printf ' %s\n' " $DESC " | sed -nE ' s/.*-([0-9]+)-g[0-9a-fA-F]+$/\1/p' )
223- N_FWD=$( printf ' %s\n' " $REL " | sed -nE ' s/.*~([0-9]+).*/\1/p' )
224- EXTRA=" describe: $DESC (${N_BACK:- ?} commits AFTER the nearest ancestor tag — that tag does NOT ship this commit)
259+ DEP_SHA=$( echo " $result " | awk ' {print $1}' )
260+ PIN_VAL=$( echo " $result " | awk ' {print $2}' )
261+
262+ EXTRA=" "
263+ if [ " $DEP " = " talos" ]; then
264+ DESC=$( git -C " $DEP_PATH " describe --tags " $DEP_SHA " 2> /dev/null || true)
265+ CONTAIN=$( git -C " $DEP_PATH " tag --contains " $DEP_SHA " 2> /dev/null \
266+ | { paste -sd ' ,' - || true ; } \
267+ | sed ' s/,/, /g' )
268+ REL=$( git -C " $DEP_PATH " describe --contains " $DEP_SHA " 2> /dev/null || true)
269+ N_BACK=$( printf ' %s\n' " $DESC " | sed -nE ' s/.*-([0-9]+)-g[0-9a-fA-F]+$/\1/p' )
270+ N_FWD=$( printf ' %s\n' " $REL " | sed -nE ' s/.*~([0-9]+).*/\1/p' )
271+ EXTRA=" describe: $DESC (${N_BACK:- ?} commits AFTER the nearest ancestor tag — that tag does NOT ship this commit)
225272 containing tags: ${CONTAIN:- <none>}
226273 position: $REL (${N_FWD:- ?} first-parent commits BEFORE the containing tag — that release SHIPS this commit)"
274+ fi
275+
276+ print_commit_block " $DEP @ $branch (first bump containing fix)" " $DEP " " $DEP_SHA " " $PIN_VAL " " $EXTRA "
277+
278+ CARRY_REPO=" $DEP "
279+ CARRY_SHA=" $DEP_SHA "
280+ done
281+ }
282+
283+ SRC_REPO=" $FROM "
284+ SRC_PATH=$( repo_path " $SRC_REPO " )
285+ if ! SRC_SHA=$( git -C " $SRC_PATH " rev-parse " $COMMIT " 2> /dev/null) ; then
286+ echo " commit $COMMIT not found in $SRC_REPO at $SRC_PATH " >&2
287+ exit 1
288+ fi
289+
290+ # Determine which branches to check.
291+ if [ -n " $BRANCH_FILTER " ]; then
292+ BRANCHES_TO_CHECK=" $BRANCH_FILTER "
293+ else
294+ BRANCHES_TO_CHECK=$( list_source_branches " $SRC_PATH " )
295+ fi
296+
297+ # For each branch, compute equivalent commit. Print summary, then traces.
298+ PRESENT_BRANCHES=" "
299+ declare -a TRACES_BRANCH TRACES_SHA
300+ for b in $BRANCHES_TO_CHECK ; do
301+ if ! branch_exists " $SRC_PATH " " $b " ; then
302+ echo " WARN: origin/$b not in $SRC_REPO — skipping." >&2
303+ continue
227304 fi
305+ if eq=$( equivalent_on_branch " $SRC_PATH " " $b " " $SRC_SHA " ) ; then
306+ PRESENT_BRANCHES=" $PRESENT_BRANCHES $b "
307+ TRACES_BRANCH+=(" $b " )
308+ TRACES_SHA+=(" $eq " )
309+ fi
310+ done
228311
229- print_commit_block " $DEP (first bump containing fix)" " $DEP " " $DEP_SHA " " $PIN_VAL " " $EXTRA "
312+ if [ ${# TRACES_BRANCH[@]} -eq 0 ]; then
313+ echo " fix not found on any checked branch in $SRC_REPO ." >&2
314+ exit 2
315+ fi
316+
317+ echo
318+ echo " Fix present on branches:$PRESENT_BRANCHES "
230319
231- CARRY_REPO=" $DEP "
232- CARRY_SHA=" $DEP_SHA "
320+ i=0
321+ while [ $i -lt ${# TRACES_BRANCH[@]} ]; do
322+ trace_chain " ${TRACES_BRANCH[$i]} " " ${TRACES_SHA[$i]} "
323+ i=$(( i + 1 ))
233324done
234325
235326echo
0 commit comments