Repository navigation
Resolve the tracked branch exactly and fetch it by explicit refspec, in both scripts - #810
Conversation
Three ways the float added by this branch could put a freshly initialized submodule somewhere other than on the tip of its tracked branch. All three need only valid `.gitmodules` configuration or ordinary refs, and none is reachable in Spine today, so this is latent robustness rather than a live fault. `git rev-parse --abbrev-ref HEAD` shortens `HEAD` to a *non-ambiguous* name, so a superproject carrying both a branch and a tag called `foo` resolved a `branch = .` declaration to `heads/foo`: the fetch succeeded and the checkout then failed, leaving the submodule at the pin with no retry. It now resolves with `git branch --show-current`, which prints the name exactly and prints nothing when `HEAD` is detached — the case the existing guard already covers. This matches `./config/pull`, which documents the same reasoning. The fetch now names its destination. A submodule declaring `shallow = true` is cloned single-branch, so for any other tracked branch a bare `git fetch origin <branch>` reached only `FETCH_HEAD` and `refs/remotes/origin/<branch>` never appeared. The checkout now asks whether the branch exists locally instead of letting Git work it out, because both answers are otherwise wrong in a reachable case. A fresh clone has a local branch only for the default branch, so `-b` alone would fail with `already exists` on the common path. Plain `git checkout <branch>` is no substitute either: it guesses from a remote-tracking ref only when the name resolves to nothing, and considers only refs the remote's fetch refspec covers, so it fails in the single-branch clone above — while a submodule carrying a *tag* of that name makes it resolve the tag and detach `HEAD`, which the following `--ff-only` fast-forwards to the right commit on no branch at all, reporting success. That last one predates this branch. Only the first fix matches `./config/pull`; the other two go beyond it, and `pull` still carries the bare fetch and checkout for the submodules it floats. Recorded in the task file as a follow-up. `merge --ff-only` is kept after either checkout path, so a local branch that has diverged from `origin/<branch>` is reported rather than overwritten, and nothing force-moves a ref but the remote-tracking ref the fetch owns, which a default clone force-updates anyway. Verified against throwaway repositories, comparing against the merged script: a submodule whose tag shadows its tracked branch ended detached and silent and now lands on the branch; a `shallow = true` submodule tracking a non-default branch and a `branch = .` superproject whose branch name is also a tag both failed and now float to the tip. An ordinary tracked branch, a plain `branch = .`, a detached superproject, second-run idempotence and the consumer-owned skip are all unchanged, and `exit 0` holds on every path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…bmodules-pr-aec482
There was a problem hiding this comment.
🟢 Approved
No unresolved findings remain, and the focused changes preserve failure handling with reported syntax and scenario checks passing.
No open issues 🎉
What changed in this PR
Hardens fresh submodule initialization so tracked branches resolve correctly even with ambiguous names or single-branch clones.
Changes:
- Resolves inherited branch names exactly and fetches into an explicit remote-tracking ref.
- Selects or creates the local branch explicitly while preserving fast-forward-only updates.
- Documents the edge cases and related follow-up work.
| File | Description |
|---|---|
init-submodules |
Makes branch resolution, fetching, and checkout explicit. |
.agents/tasks/init-submodules-float-to-branch-tip.md |
Records the fixes and their rationale. |
Review effort: Balanced
`init-submodules` stopped relying on Git to work out where a fetch lands and which branch a checkout means. `pull` floats the same submodules with the same three steps, and carried both of the problems that motivated that change, so it gets the same treatment rather than being left to diverge. A bare `git fetch origin <branch>` leaves it to the refspec configured for the remote to update `refs/remotes/origin/<branch>` as well. A submodule declaring `shallow = true` in `.gitmodules` is cloned single-branch, so any other tracked branch reached `FETCH_HEAD` alone and `origin/<branch>` never appeared; the float then warned and left the submodule detached at the pin. A bare `git checkout <branch>` guesses the branch from a remote-tracking ref only when the name resolves to nothing, over just the refs that refspec covers. Where the submodule carries a tag of that name the name does resolve, so Git checked out the tag and detached `HEAD`, after which `--ff-only` fast-forwarded that detached `HEAD` to the branch tip and reported success. The submodule ended on the right commit, on no branch, with nothing warned at all. That silent case matters more here than in `init-submodules`, which only ever touches a submodule it has just cloned. `pull` floats submodules that are already checked out and in use, so `--ff-only` still guards both checkout paths and nothing force-moves a ref but the remote-tracking ref the fetch owns. Verified end to end against throwaway consumer repositories built with a `config` submodule and a shared one, so the real `./config/pull` runs all three of its steps. A `shallow = true` submodule tracking a non-default branch was detached and warned about and now lands on the branch; one whose tag shadows its tracked branch was detached with no warning and now lands on the branch. A submodule whose local branch has diverged from `origin/<branch>` keeps its commit and reports nothing, exactly as before — the `update = merge` strategy and `--ff-only` between them already covered that. An ordinary tracked branch and a second run are unchanged. `pull` keeps its `source migrate` SC1091, which predates this change; no new `shellcheck` findings, and `bash -n` is clean. The parity clause in `init-submodules` is reworded, since both scripts now name the destination and the start point, and the task file records the change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of the previous commit caught its verification paragraph claiming that a
submodule whose local branch has diverged from `origin/<branch>` "keeps its
commit and reports nothing". Only the first half is right.
The fixture behind that claim had the submodule's recorded pin at the tip of
the tracked branch, so `update = merge` merged the branch tip into the local
branch before the float ever ran, and `--ff-only` then had nothing to do. The
remote had never moved past the pin, so the branches were not really divergent.
Re-tested with the remote advanced as well, and with both the `merge` and the
default `checkout` strategy, the two cases separate cleanly and neither depends
on the strategy:
* a local branch merely ahead of `origin/<branch>` floats silently and keeps
its commits;
* one that has genuinely diverged fails `--ff-only` and is reported by the
existing warning, also keeping its commits.
So local work is safe either way, which is the property that matters for
`pull`; the two differ only in whether anything is said about it. The task file
now states both, and notes where the earlier claim came from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Re-requesting review: this PR has grown since the last pass. It now also gives
The earlier approval predates both, and @codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a1b46b5fe6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…hand The float spelled its fetch destination and its checkout start point out as full refs, then handed `merge --ff-only` the `origin/<branch>` shorthand. gitrevisions(7) resolves a bare name through `refs/tags/` *before* `refs/remotes/`, so a tag literally named `origin/<branch>` wins that shorthand — and an ordinary clone fetches tags, so it is there to win it. The result was worse than the ambiguity it came from. The checkout placed the branch exactly, at `refs/remotes/origin/<branch>`, and the merge then fast-forwarded it onto whatever the tag pointed at and reported success. Where such a tag sits off the branch — after the branch was force-reset back past it, say — the submodule ends up on the right branch at the wrong commit, with nothing warned. For the narrow-clone path this change turned a warned failure into a silent wrong float, which is the one trade this work set out to avoid. Reproduced both at the level of the git commands and end to end: with a remote carrying an off-branch tag named `origin/other`, `git rev-parse origin/other` returns the tag's commit, and the previous script left the submodule on `refs/heads/other` at that stale commit. With the full ref it lands on the branch tip. Both scripts now use `refs/remotes/origin/$branch` throughout, so no step in the chain can be captured by a ref of another kind. The regression matrices for `init-submodules` (shallow, tag-shadowed branch, `branch = .` with and without a name-sharing tag, ordinary branch, detached superproject) and for `pull` (shallow, tag-shadowed branch, ordinary branch, local commits) are unchanged. Found by review. It was raised earlier as an unlikely nit and left alone; the point that moved it was that the new exact checkout makes the stale merge silent rather than merely ambiguous. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Problem
PR #802 taught
init-submodulesto float a freshly initialized config-managedsubmodule to the tip of its tracked branch. Review of that change — and of this
one — turned up three ways the float can leave the submodule somewhere other
than on that tip. Each needs only valid
.gitmodulesconfiguration orordinary refs; none is reachable in Spine today, where every tracked branch is
the literal
masterand no.gitmodulesdeclaresshallow. This is latentrobustness, not a live fault.
1. An ambiguous branch name. A
branch = .declaration is resolved againstthe superproject's current branch.
git rev-parse --abbrev-ref HEADshortensHEADto a non-ambiguous name, so a superproject carrying both a branch anda tag called
fooyieldedheads/foo. The fetch succeeds, the checkout thenfails, and the submodule keeps the pin — and is never retried, because it is no
longer a
-line.2. A single-branch submodule clone. A submodule declaring
shallow = trueis cloned single-branch (a shallow clone implies
--single-branch), as+refs/heads/<default>:refs/remotes/origin/<default>. A baregit fetch origin <branch>relies on the refspec configured for the remote toupdate
refs/remotes/origin/<branch>opportunistically as well, which a fullclone's does and that one's does not — so any other tracked branch reached
FETCH_HEADalone andorigin/<branch>never appeared.3. A tag shadowing the tracked branch (predates #802). Plain
git checkout <branch>guesses a branch from a remote-tracking ref only whenthe name resolves to nothing. If the submodule carries a tag of that name, the
name resolves, so Git quietly checks the tag out and detaches
HEAD. Thefollowing
merge --ff-onlythen fast-forwards that detachedHEADto thebranch tip and reports success. The submodule ends on the right commit, on no
branch at all, with no warning — contradicting the script's own promise to leave
it "ON the branch rather than in a detached
HEAD".4. A tag named
origin/<branch>capturing the merge. Found while reviewingthis PR. Even with the fetch and the checkout spelled out as full refs, the
final
merge --ff-onlywas handed theorigin/<branch>shorthand.gitrevisions(7) resolves a bare name through
refs/tags/beforerefs/remotes/, so a tag of that exact name wins it, and an ordinary clonefetches tags, so it is there to win. The checkout placed the branch exactly and
the merge then fast-forwarded it onto whatever the tag pointed at, reporting
success: where the tag sits off the branch — after a force-reset past it — the
submodule ends on the right branch at the wrong commit, silently. For the
narrow-clone path that turned a warned failure into a silent wrong float, which
is precisely the trade this PR exists to avoid.
Change
init-submodulesbranch = .withgit branch --show-current, which prints the nameexactly and prints nothing when
HEADis detached — the case the existingguard already handles.
./config/pullresolves it this way and documents thesame reasoning.
+refs/heads/<branch>:refs/remotes/origin/<branch>. The leading+matcheswhat a default clone configures.
git show-ref --verifywhether the branch exists locally instead ofletting Git work the checkout out. Both answers are otherwise wrong in a
reachable case: a fresh clone has a local branch only for the default branch,
so
-balone would fail withalready existson the common path, while plaingit checkoutcarries problems 2 and 3 above.refs/remotes/origin/<branch>rather than the shorthand, so problem 4cannot undo the exact checkout. Every ref in the chain is now fully
qualified, in both scripts.
merge --ff-onlyis kept after either checkout path, so a local branch that hasdiverged from
origin/<branch>is reported rather than overwritten. Nothingforce-moves a ref except the remote-tracking ref the fetch owns, which a default
clone force-updates anyway.
exit 0semantics are untouched, so theSessionStarthook still cannot fail a session../config/pullpullfloats the same submodules with the same three steps and carried bothproblems 2 and 3, so it gets the same fetch destination and the same
show-ref-guarded checkout rather than being left to diverge. Problem 1 wasalready fixed there in #804.
The silent detach of problem 3 matters more in
pullthan ininit-submodules, which only ever touches a submodule it has just cloned:pullfloats submodules that are already checked out and in use and maycarry local work. So
--ff-onlystill guards both checkout paths there too, andthe one case that protects is verified below.
pullkeeps its pre-existingsource migrateSC1091; the change adds no newshellcheckfindings.Verification
Against throwaway repositories, each case run with the merged script and with
this one:
896480d2shallow = true, non-default tracked branchbranch = ., superproject branch name also a tagorigin/<branch>off the branchbranch = ., unambiguous namebranch = .exit 0on every path above.bash -nandshellcheckare clean.For
pull, end to end against throwaway consumer repositories built with aconfigsubmodule and a shared one, so the real./config/pullruns all threeof its own steps:
shallow = true, non-default tracked branchorigin/<branch>The local-work cases are what matter for
pull, and they are unchanged: thecommits survive either way, and the two differ only in whether anything is
said. A branch merely ahead of the remote fast-forwards silently; one with
commits on both sides fails
--ff-onlyand hits the existing warning. Neitherdepends on the
updatestrategy.The verification paragraph of the first
pullcommit called the diverged casesilent. That was wrong, and the third commit corrects it: the fixture behind it
had the submodule's pin already at the branch tip, so
update = mergereconciled the branches before the float ran. Caught in review, re-tested with
the remote advanced as well and under both strategies.
Reviewed by the
spine-code-reviewandreview-docsagents. Both returned"approve with changes" with no must-fix items; problem 3 above is the code
reviewer's finding, reproduced before being fixed, and its correction of the
--ff-onlyguarantee (it requiresHEADto be an ancestor of the branch tip,not a clean working tree) is applied to the comments.
Notes
pullstill carried the bare fetch and bare checkout and left that for a follow-up.
That follow-up is now in this PR instead, so the two scripts stay in step;
the parity clause in the
init-submodulescomment is reworded to match. Theloop-stdin guard
init-submodulescarries is deliberately not added topull: with three config-managed submodules declared, all three floated, soit is not reachable there today, and adding it would widen this diff for no
demonstrable fault. Review added a reason to keep it out rather than add it
half-way — the stdin-reading candidate in
pull's loop is its unguardedgit submodule update --init, which sits outside the subshell, so guardingonly the subshell would not close the gap. If parity is wanted, both lines
should get it, as its own hardening change.
now auto-follows tags that point into the fetched branch (additive only;
existing tags are never rewritten), and in a single-branch clone the local
branch it creates has no upstream tracking, since the configured refspec does
not cover it. Nothing here relies on either.
come as their own PR rather than as commits on that branch.
🤖 Generated with Claude Code