Two repos looked stale because the clone only had one branch
Shipped
0.5.1 fixed how a fan-out job reads other repositories. It cloned each consumer with --depth=1 and inspected whatever the default branch had, then reported two of them as missing a generated block they demonstrably contained. Nothing was wrong with those repos. The block had been merged to their integration branch, and the clone could not see that branch existed.
Any job that reaches into repositories it does not own runs into the same two decisions: how much history to fetch, and which branch to read and target. This guide builds the fan-out loop, reproduces the wrong answer in a scratch directory with real output, and fixes both decisions.
Setup: what a fan-out needs from a clone
The pattern is a job that, for each downstream repository, clones it, regenerates something inside it, and opens a pull request if the result differs. You need three things from the clone: the current content of the file you own, the branch a pull request should target, and enough history to push a branch.
The naive version gets all three wrong in one line.
#!/usr/bin/env bash
set -euo pipefail
# The version that looks efficient and reads the wrong thing.
for repo in "$@"; do
work=$(mktemp -d)
gh repo clone "$repo" "$work" -- --depth=1 --quiet
if [ -f "$work/brand.txt" ]; then
echo "$repo: $(cat "$work/brand.txt")"
else
echo "$repo: missing"
fi
done
Everything after the -- is handed straight to git clone by gh repo clone, so --depth=1 here is git’s flag, with git’s semantics. It is there because history is not needed and shallow is faster. That reasoning is fine. The side effect is not.
The clone that hides half the repository
Git’s clone documentation spells it out under --depth: “Create a shallow clone with a history truncated to the specified number of commits. Implies --single-branch unless --no-single-branch is given”. And --single-branch means “Clone only the history leading to the tip of a single branch, either specified by the --branch option or the primary branch remote’s HEAD points at.”
So a shallow clone gives you exactly one branch, and it is the default branch. Build the situation in a scratch directory and look at it directly. This runs offline in about a second:
mkdir demo && cd demo
git init -q --bare -b main origin.git
git clone -q origin.git seed && cd seed
git config user.email d@e.f && git config user.name demo
echo "orange" > brand.txt
git add brand.txt && git commit -qm "brand on main" && git push -q origin main
git checkout -qb dev
echo "orange-refreshed" > brand.txt
git commit -qam "brand migration lands on dev" && git push -q origin dev
cd ..
echo "=== shallow clone ==="
git clone -q --depth=1 "file://$PWD/origin.git" shallow
git -C shallow branch -r
echo "origin/dev present? $(git -C shallow rev-parse --verify --quiet origin/dev >/dev/null && echo yes || echo no)"
echo "=== full clone ==="
git clone -q "file://$PWD/origin.git" full
git -C full branch -r
echo "origin/dev present? $(git -C full rev-parse --verify --quiet origin/dev >/dev/null && echo yes || echo no)"
echo "=== what each clone reads ==="
echo "shallow: $(cat shallow/brand.txt)"
echo "full(dev): $(git -C full show origin/dev:brand.txt)"
Running that prints:
=== shallow clone ===
origin/HEAD -> origin/main
origin/main
origin/dev present? no
=== full clone ===
origin/HEAD -> origin/main
origin/dev
origin/main
origin/dev present? yes
=== what each clone reads ===
shallow: orange
full(dev): orange-refreshed
The shallow clone is not broken and did not warn. It answered a narrower question than the one being asked, and the migration sitting on dev was invisible to it. That is what produced a report of “missing” for repositories where the work was merged and waiting to promote.
Choose the base branch on purpose
The second decision is which branch the pull request targets. GitHub’s own definition is that “The default branch is the base branch for pull requests and code commits” (changing the default branch), and that default is what gh and the web UI will pick for you. If the repositories you are automating run feature → dev → main, a bot opening a pull request straight into main is asking them to violate their own flow, and it will sit there.
Resolve the base explicitly instead. Ask the checkout whether the integration branch exists, and fall back to whatever the repository says its default is:
# resolve_base <checkout> <owner/repo>
# The branch a fan-out PR should target: the integration branch when the remote
# has one, the repository's own default branch otherwise.
resolve_base() {
local work="$1" repo="$2"
if git -C "$work" rev-parse --verify --quiet origin/dev >/dev/null; then
echo dev
else
gh repo view "$repo" --json defaultBranchRef --jq .defaultBranchRef.name
fi
}
rev-parse --verify --quiet is the right primitive here rather than grepping git branch -r. The rev-parse docs define --verify as verifying “that exactly one parameter is provided and that it can be turned into a raw 20-byte SHA-1”, and --quiet as suppressing the error message so it “exits with non-zero status silently”. You get a clean boolean with no output parsing.
Wire it into the fan-out loop
Full clone, resolved base, explicit checkout, and the base carried through to the pull request:
#!/usr/bin/env bash
set -euo pipefail
for repo in "$@"; do
work=$(mktemp -d)
gh repo clone "$repo" "$work" -- --quiet # full clone: every branch
base=$(resolve_base "$work" "$repo")
git -C "$work" checkout -q "$base"
echo "$repo: base branch $base"
# ... regenerate the file you own inside "$work" ...
git -C "$work" diff --quiet && { echo "$repo: current"; continue; }
branch="fanout/$(date +%Y%m%d)-$base"
git -C "$work" config user.name "fanout[bot]"
git -C "$work" config user.email "noreply@github.com"
git -C "$work" checkout -q -b "$branch"
git -C "$work" commit -qam "chore: refresh generated block"
git -C "$work" push -q origin "$branch"
gh pr create --repo "$repo" --base "$base" --head "$branch" \
--title "chore: refresh generated block" --body "Automated fan-out."
done
Two details earn their place. The base branch is echoed on every iteration, so a run’s log answers “which branch did you read?” without a re-run. And the branch name has the base suffixed into it, because the same fan-out may need to land on more than one long-lived branch, and two pushes of the same branch name would collide.
Use it, then verify it
Save resolve_base as resolve.sh, source it, and point it at both clones from the demo. gh is stubbed so the check stays offline:
. ./resolve.sh
gh() { echo "main"; } # stub: no network in this check
echo "full clone -> $(resolve_base full demo/demo)"
echo "shallow clone -> $(resolve_base shallow demo/demo)"
That prints:
full clone -> dev
shallow clone -> main
The shallow clone does not report an error, and it does not report dev. It reports the wrong branch confidently, which is the behaviour to design against. Check the content the resolved base actually gives you:
b=$(resolve_base full demo/demo)
git -C full checkout -q "$b"
cat full/brand.txt
orange-refreshed
Gotchas
--depth=1 silently narrows what you can see. Shallow implies single-branch, so every other branch is simply absent from the clone. Symptom: a script reports a file or a branch as missing in a repository where it plainly exists. Escape: drop --depth when you need to look around, or keep it and be specific with --depth=1 --branch "$base" once you already know the branch, or pass --no-single-branch to keep the shallow fetch and get the refs.
--depth is ignored entirely for local path clones. Reproducing this locally, git prints warning: --depth is ignored in local clones; use file:// instead and hands you a full clone, so the bug you are trying to reproduce disappears. Symptom: your minimal repro cannot reproduce it. Escape: use a file:// URL, as the demo above does.
The default branch can legitimately lag, so reading it reports something true and misleading. A repository mid-promotion has the merged work on its integration branch and not yet on the default. Symptom: a report that says “missing” or “out of date” for repos whose maintainers already merged the change. Escape: resolve the base explicitly, and log it on every iteration.
A bot pull request into the wrong base is worse than no pull request. It cannot be merged without breaking the receiving repo’s own branch protection, so it sits open and gets ignored, and the next release stacks another one behind it. Escape: target the branch the humans target.
Not every repository has your integration branch. The fallback path is load-bearing, not decoration; without it the resolver returns an empty string and git checkout "" fails in a way that reads like a permissions problem. Escape: fall back to gh repo view --json defaultBranchRef, and test the fallback by pointing the resolver at a repo that has no dev.
This class of bug does not surface from reading the code. The clone flag and the base-branch choice are each defensible in isolation, and the failure only appears against a real repository whose branches disagree. It surfaced here from dispatching the job by hand and reading its output, before a scheduled run made it expensive to be wrong.
Sources
- git-clone documentation —
--depthimplies--single-branch, and what single-branch actually fetches. - git-rev-parse documentation —
--verifyand--quietas the script-safe ref existence check. - Changing the default branch — the default branch is the base branch for pull requests.
- gh repo clone — passing flags through to
git cloneafter the--separator.
Changelog
- fix(press): propagate to the integration branch, not the default (0.5.1) (#127) (d611a4d)