Skip to content

Commit 439ec14

Browse files
authored
Merge pull request #2 from pyTooling/claude/create-missing-branches
Create a branch that is missing in the fork
2 parents 191622e + b1aaa86 commit 439ec14

4 files changed

Lines changed: 116 additions & 5 deletions

File tree

‎.github/actions/check-outputs/action.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ inputs:
2222
description: Actual number of synchronized branches.
2323
type: string
2424
required: true
25+
created:
26+
description: Actual number of created branches.
27+
type: string
28+
required: true
2529
skipped:
2630
description: Actual number of skipped entries.
2731
type: string
@@ -34,6 +38,10 @@ inputs:
3438
description: Expected number of synchronized branches.
3539
type: string
3640
required: true
41+
expected-created:
42+
description: Expected number of created branches.
43+
type: string
44+
required: true
3745
expected-skipped:
3846
description: Expected number of skipped entries.
3947
type: string
@@ -50,9 +58,11 @@ runs:
5058
shell: bash
5159
env:
5260
SYNCHRONIZED: ${{ inputs.synchronized }}
61+
CREATED: ${{ inputs.created }}
5362
SKIPPED: ${{ inputs.skipped }}
5463
ERRORS: ${{ inputs.errors }}
5564
EXPECTED_SYNCHRONIZED: ${{ inputs.expected-synchronized }}
65+
EXPECTED_CREATED: ${{ inputs.expected-created }}
5666
EXPECTED_SKIPPED: ${{ inputs.expected-skipped }}
5767
EXPECTED_ERRORS: ${{ inputs.expected-errors }}
5868
run: |
@@ -78,6 +88,7 @@ runs:
7888
}
7989
8090
checkOutput "synchronized" "${SYNCHRONIZED}" "${EXPECTED_SYNCHRONIZED}"
91+
checkOutput "created" "${CREATED}" "${EXPECTED_CREATED}"
8192
checkOutput "skipped" "${SKIPPED}" "${EXPECTED_SKIPPED}"
8293
checkOutput "errors" "${ERRORS}" "${EXPECTED_ERRORS}"
8394

‎.github/workflows/Verification.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@ jobs:
4343
uses: ./.github/actions/check-outputs
4444
with:
4545
synchronized: ${{ steps.synchronize.outputs.synchronized }}
46+
created: ${{ steps.synchronize.outputs.created }}
4647
skipped: ${{ steps.synchronize.outputs.skipped }}
4748
errors: ${{ steps.synchronize.outputs.errors }}
4849
# 1 branch of 'ghdl' + 1 branch of 'progit2' + 2 branches of 'itertree'
4950
expected-synchronized: 4
5051
# 1 commented out organisation + 1 commented out repository
52+
# Dry-run reads no repository, so no branch is created.
53+
expected-created: 0
5154
expected-skipped: 2
5255
expected-errors: 0
5356

@@ -73,9 +76,11 @@ jobs:
7376
uses: ./.github/actions/check-outputs
7477
with:
7578
synchronized: ${{ steps.synchronize.outputs.synchronized }}
79+
created: ${{ steps.synchronize.outputs.created }}
7680
skipped: ${{ steps.synchronize.outputs.skipped }}
7781
errors: ${{ steps.synchronize.outputs.errors }}
7882
expected-synchronized: 1
83+
expected-created: 0
7984
expected-skipped: 0
8085
# 1 missing 'Missing.repos' + 4 malformed lines in 'ghdl.repos'
8186
expected-errors: 5

‎README.md‎

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,17 +120,20 @@ Every organisation becomes a collapsible group, and each fork reports the branch
120120
✅ gh repo sync PLC2/OSVVM --branch main
121121
✅ gh repo sync PLC2/OSVVM --branch dev
122122
📂 OSVVM/AXI4 ⇒ PLC2/OSVVM-AXI4
123-
❌ gh repo sync PLC2/OSVVM-AXI4 --branch dev
123+
🌱 dev — created from OSVVM/AXI4@a1b2c3d
124+
📂 OSVVM/Ethernet ⇒ PLC2/OSVVM-Ethernet
125+
❌ gh repo sync PLC2/OSVVM-Ethernet --branch main
124126
↪ failed to sync: HTTP 404: Not Found
125127
🚫 #OSVVM/AvalonST=OSVVM-AvalonST:main
126128

127129
Summary:
128130
Synchronized branches: 2
131+
Created branches: 1
129132
Skipped entries: 1
130133
Errors: 1
131134

132135
Not synchronized:
133-
❌ OSVVM/AXI4 ⇒ PLC2/OSVVM-AXI4:dev
136+
❌ OSVVM/Ethernet ⇒ PLC2/OSVVM-Ethernet:main
134137
```
135138
136139
### Input Parameters
@@ -142,6 +145,7 @@ Not synchronized:
142145
| `directory` | no | `'.'` | Directory containing the configuration files. |
143146
| `index-file` | no | `'.ALL.repos'` | Name of the index file listing the organisations to be synchronized. |
144147
| `force` | no | `false` | Hard reset the fork's branch to the upstream branch, discarding commits that aren't in the upstream repository. |
148+
| `create-missing-branches` | no | `false` | Create a listed branch that doesn't exist in the fork yet from the upstream repository's branch head. |
145149
| `dry-run` | no | `false` | Print the synchronization commands instead of running them. |
146150
| `fail-on-error` | no | `true` | Let the action fail if at least one error was counted. |
147151
@@ -150,10 +154,35 @@ Not synchronized:
150154
| Parameter | Description |
151155
|----------------|---------------------------------------------------------------------------|
152156
| `synchronized` | Number of successfully synchronized branches. |
157+
| `created` | Number of branches created in a fork. |
153158
| `skipped` | Number of skipped organisations and repositories (commented out lines). |
154159
| `errors` | Number of counted errors. |
155160
156-
In dry-run mode, `synchronized` counts the branches that *would* have been synchronized.
161+
In dry-run mode, `synchronized` counts the branches that *would* have been synchronized. Dry-run reads no repository,
162+
so a missing branch isn't detected and `created` stays `0`.
163+
164+
## Missing Branches
165+
166+
`gh repo sync` updates a branch; it can't create one. A branch listed in an `<organisation>.repos` file that the fork
167+
doesn't have yet — a branch added upstream after the fork was created, or a fork made with *Copy the default branch
168+
only* — therefore can't be synchronized at all.
169+
170+
With `create-missing-branches: true`, the action creates it from the upstream repository's branch head, and the next
171+
run synchronizes it like any other branch:
172+
173+
```
174+
📂 OSVVM/AXI4 ⇒ PLC2/OSVVM-AXI4
175+
🌱 dev — created from OSVVM/AXI4@a1b2c3d
176+
```
177+
178+
**It's off by default**, because this is where `<upstream>` stops being decoration: it's the repository the new
179+
branch's head is read from, and a stale or copy-pasted upstream would create the branch from the wrong repository.
180+
Check that field, then enable it per repository. While it's disabled, a missing branch is a counted error naming the
181+
parameter.
182+
183+
No clone, fetch or push is involved. GitHub keeps a fork and its upstream in one object network, so the upstream's
184+
commit is addressable through the fork and the branch is created with a single API call. If the branch exists in
185+
neither repository — usually a typo in the configuration file — it's a counted error.
157186
158187
## Error Handling
159188
@@ -164,7 +193,9 @@ Each of these is counted, reported as a GitHub Actions error annotation, and let
164193
* an `<organisation>.repos` file listed in the index file doesn't exist,
165194
* a repository line is malformed — no `=`, no `:`, or an empty `<upstream>`, `<fork>` or branch list,
166195
* `gh repo sync` fails for a fork's branch. Its output is quoted below the failed command, and the annotation names the
167-
upstream repository, the fork and the branch.
196+
upstream repository, the fork and the branch,
197+
* a branch exists neither in the fork nor in the upstream repository, or creating it fails,
198+
* a branch is missing from the fork while `create-missing-branches` is disabled.
168199
169200
A failing fork doesn't stop the run: every other fork is still synchronized, and the summary lists what wasn't.
170201

‎action.yml‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,20 @@ inputs:
5555
type: boolean
5656
required: false
5757
default: false
58+
create-missing-branches:
59+
description: |
60+
Create a branch that is listed in an '<organisation>.repos' file, but doesn't exist in the fork yet, from the
61+
upstream repository's branch head. 'gh repo sync' can only update an existing branch.
62+
The branch head is read from '<upstream>', so this is disabled by default: a stale or copy-pasted upstream in a
63+
configuration file would create the branch from the wrong repository.
64+
type: boolean
65+
required: false
66+
default: false
5867
dry-run:
5968
description: |
6069
Print the synchronization commands instead of running them.
61-
Branches that would have been synchronized are counted in the 'synchronized' output parameter.
70+
Branches that would have been synchronized are counted in the 'synchronized' output parameter. No repository is
71+
read or written, so a missing branch isn't detected in this mode.
6272
type: boolean
6373
required: false
6474
default: false
@@ -74,6 +84,10 @@ outputs:
7484
description: |
7585
Number of successfully synchronized branches.
7686
value: ${{ steps.synchronize.outputs.synchronized }}
87+
created:
88+
description: |
89+
Number of branches created in a fork.
90+
value: ${{ steps.synchronize.outputs.created }}
7791
skipped:
7892
description: |
7993
Number of skipped organisations and repositories (commented out lines).
@@ -114,6 +128,7 @@ runs:
114128
TARGET_ORGANISATION: ${{ inputs.target-organisation }}
115129
INDEX_FILE: ${{ inputs.index-file }}
116130
FORCE: ${{ inputs.force }}
131+
CREATE_BRANCHES: ${{ inputs.create-missing-branches }}
117132
DRY_RUN: ${{ inputs.dry-run }}
118133
FAIL_ON_ERROR: ${{ inputs.fail-on-error }}
119134
run: |
@@ -137,10 +152,38 @@ runs:
137152
printf "%s\n" ""
138153
139154
synchronized=0
155+
created=0
140156
skipped=0
141157
errors=0
142158
failures=()
143159
160+
# Create a branch in the fork, pointing at the upstream repository's branch head. GitHub keeps a fork and its
161+
# upstream in one object network, so the upstream's commit is addressable through the fork and no clone,
162+
# fetch or push is involved. 'gh repo sync' can only fast-forward a branch that already exists.
163+
createBranch() {
164+
local upstream="${1}" fork="${2}" branch="${3}"
165+
local sha messages
166+
167+
sha="$(gh api "repos/${upstream}/git/ref/heads/${branch}" --jq '.object.sha' 2>&1)"
168+
if [[ $? -ne 0 ]]; then
169+
printf " ❌ %s\n" "${ANSI_LIGHT_RED}${branch} — exists neither in the fork nor in '${upstream}'${ANSI_NOCOLOR}"
170+
printf "::error title=%s::%s\n" "Create ${fork}:${branch}" "Branch '${branch}' exists neither in the fork '${fork}' nor in the upstream repository '${upstream}'."
171+
return 1
172+
fi
173+
174+
messages="$(gh api --method POST "repos/${fork}/git/refs" --field "ref=refs/heads/${branch}" --field "sha=${sha}" 2>&1)"
175+
if [[ $? -ne 0 ]]; then
176+
printf " ❌ %s\n" "${ANSI_LIGHT_RED}${branch} — couldn't be created from ${upstream}@${sha:0:7}${ANSI_NOCOLOR}"
177+
printf "%s\n" "$(sed 's/^/ ↪ /g' <<< "${messages}")"
178+
printf "::error title=%s::%s\n" "Create ${fork}:${branch}" "${messages//$'\n'/%0A}"
179+
return 1
180+
fi
181+
182+
printf " 🌱 %s\n" "${ANSI_LIGHT_GREEN}${branch} — created from ${upstream}@${sha:0:7}${ANSI_NOCOLOR}"
183+
created=$((created + 1))
184+
return 0
185+
}
186+
144187
if [[ ! -f "${INDEX_FILE}" ]]; then
145188
printf "%s\n" "${ANSI_LIGHT_RED}Index file '${INDEX_FILE}' not found in '$(pwd)'.${ANSI_NOCOLOR}"
146189
printf "::error title=%s::%s\n" "pyTooling/SynchronizeForks" "Index file '${INDEX_FILE}' not found in '$(pwd)'."
@@ -215,6 +258,25 @@ runs:
215258
continue
216259
fi
217260
261+
# A branch that doesn't exist in the fork can't be synchronized — 'gh repo sync' updates a branch, it
262+
# doesn't create one. Create it here, so the next run synchronizes it like any other branch.
263+
if ! gh api "repos/${TARGET_ORGANISATION}/${repository}/git/ref/heads/${branch}" > /dev/null 2>&1; then
264+
if [[ "${CREATE_BRANCHES}" == "true" ]]; then
265+
createBranch "${upstream}" "${TARGET_ORGANISATION}/${repository}" "${branch}"
266+
if [[ $? -ne 0 ]]; then
267+
failures+=("${upstream} ⇒ ${TARGET_ORGANISATION}/${repository}:${branch}")
268+
errors=$((errors + 1))
269+
fi
270+
else
271+
printf " ❌ %s\n" "${ANSI_LIGHT_RED}${branch} — doesn't exist in the fork${ANSI_NOCOLOR}"
272+
printf " ↪ %s\n" "${ANSI_LIGHT_RED}enable 'create-missing-branches' to create it${ANSI_NOCOLOR}"
273+
printf "::error title=%s::%s\n" "Synchronize ${upstream} ⇒ ${TARGET_ORGANISATION}/${repository}:${branch}" "Branch '${branch}' doesn't exist in the fork. Enable 'create-missing-branches' to create it from '${upstream}'."
274+
failures+=("${upstream} ⇒ ${TARGET_ORGANISATION}/${repository}:${branch}")
275+
errors=$((errors + 1))
276+
fi
277+
continue
278+
fi
279+
218280
messages="$("${syncCommand[@]}" 2>&1)"
219281
if [[ $? -eq 0 ]]; then
220282
printf " ✅ %s\n" "${syncCommand[*]}"
@@ -237,6 +299,7 @@ runs:
237299
printf "%s\n" ""
238300
printf "%s\n" "${ANSI_CYAN}Summary:${ANSI_NOCOLOR}"
239301
printf " %-24s%s\n" "Synchronized branches:" "${synchronized}"
302+
printf " %-24s%s\n" "Created branches:" "${created}"
240303
printf " %-24s%s\n" "Skipped entries:" "${skipped}"
241304
printf " %-24s%s\n" "Errors:" "${errors}"
242305
@@ -247,6 +310,7 @@ runs:
247310
fi
248311
249312
printf "synchronized=%s\n" "${synchronized}" >> "${GITHUB_OUTPUT}"
313+
printf "created=%s\n" "${created}" >> "${GITHUB_OUTPUT}"
250314
printf "skipped=%s\n" "${skipped}" >> "${GITHUB_OUTPUT}"
251315
printf "errors=%s\n" "${errors}" >> "${GITHUB_OUTPUT}"
252316

0 commit comments

Comments
 (0)