Skip to content

Resolve the tracked branch exactly and fetch it by explicit refspec, in both scripts - #810

Merged
alexander-yevsyukov merged 5 commits into
masterfrom
init-submodules-exact-branch-resolution
Oct 19, 2026
Merged

alexander-yevsyukov merged 5 commits into
masterfrom
init-submodules-exact-branch-resolution

Conversation

@alexander-yevsyukov

@alexander-yevsyukov alexander-yevsyukov commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Problem

PR #802 taught init-submodules to float a freshly initialized config-managed
submodule 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 .gitmodules configuration or
ordinary refs; none is reachable in Spine today, where every tracked branch is
the literal master and no .gitmodules declares shallow. This is latent
robustness, not a live fault.

1. An ambiguous branch name. A branch = . declaration is resolved against
the superproject's current branch. 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 yielded heads/foo. The fetch succeeds, the checkout then
fails, 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 = true
is cloned single-branch (a shallow clone implies --single-branch), as
+refs/heads/<default>:refs/remotes/origin/<default>. A bare
git fetch origin <branch> relies on the refspec configured for the remote to
update refs/remotes/origin/<branch> opportunistically as well, which a full
clone's does and that one's does not — so any other tracked branch reached
FETCH_HEAD alone and origin/<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 when
the 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. The
following merge --ff-only then fast-forwards that detached HEAD to the
branch 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 reviewing
this PR. Even with the fetch and the checkout spelled out as full refs, the
final merge --ff-only was handed the origin/<branch> shorthand.
gitrevisions(7) resolves a bare name through refs/tags/ before
refs/remotes/, so a tag of that exact name wins it, and an ordinary clone
fetches 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-submodules

  • Resolve branch = . with git branch --show-current, which prints the name
    exactly and prints nothing when HEAD is detached — the case the existing
    guard already handles. ./config/pull resolves it this way and documents the
    same reasoning.
  • Name the fetch destination:
    +refs/heads/<branch>:refs/remotes/origin/<branch>. The leading + matches
    what a default clone configures.
  • Ask git show-ref --verify whether the branch exists locally instead of
    letting 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 -b alone would fail with already exists on the common path, while plain
    git checkout carries problems 2 and 3 above.
  • Merge refs/remotes/origin/<branch> rather than the shorthand, so problem 4
    cannot undo the exact checkout. Every ref in the chain is now fully
    qualified, in both scripts.

merge --ff-only is kept after either checkout path, so a local branch that has
diverged from origin/<branch> is reported rather than overwritten. Nothing
force-moves a ref except the remote-tracking ref the fetch owns, which a default
clone force-updates anyway. exit 0 semantics are untouched, so the
SessionStart hook still cannot fail a session.

./config/pull

pull floats the same submodules with the same three steps and carried both
problems 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 was
already fixed there in #804.

The silent detach of problem 3 matters more in pull 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 and may
carry local work. So --ff-only still guards both checkout paths there too, and
the one case that protects is verified below. pull keeps its pre-existing
source migrate SC1091; the change adds no new shellcheck findings.

Verification

Against throwaway repositories, each case run with the merged script and with
this one:

Scenario merged 896480d2 this PR
submodule tag shadows the tracked branch detached, no warning on the branch
shallow = true, non-default tracked branch detached at pin, warned on the branch, at the tip
branch = ., superproject branch name also a tag detached at pin, warned on the branch, at the tip
tag named origin/<branch> off the branch on the branch at the stale tag commit, silent on the branch, at the tip
ordinary tracked branch, full clone on the branch unchanged
branch = ., unambiguous name on the branch unchanged
detached superproject, branch = . warns, keeps pin unchanged
second run (idempotence), consumer-owned skip no-op / skipped unchanged

exit 0 on every path above. bash -n and shellcheck are clean.

For pull, 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 own steps:

Scenario before after
shallow = true, non-default tracked branch detached at pin, warned on the branch
submodule tag shadows the tracked branch detached, no warning on the branch
local branch merely ahead of origin/<branch> commit kept, silent unchanged
local branch genuinely diverged commit kept, warned unchanged
ordinary tracked branch on the branch unchanged
second run (idempotence) — no-op

The local-work cases are what matter for pull, and they are unchanged: the
commits 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-only and hits the existing warning. Neither
depends on the update strategy.

The verification paragraph of the first pull commit called the diverged case
silent. 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 = merge
reconciled 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-review and review-docs agents. 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-only guarantee (it requires HEAD to be an ancestor of the branch tip,
not a clean working tree) is applied to the comments.

Notes

  • An earlier revision of this PR, and the body of its first commit, said pull
    still 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-submodules comment is reworded to match. The
    loop-stdin guard init-submodules carries is deliberately not added to
    pull: with three config-managed submodules declared, all three floated, so
    it 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 unguarded
    git submodule update --init, which sits outside the subshell, so guarding
    only the subshell would not close the gap. If parity is wanted, both lines
    should get it, as its own hardening change.
  • Two benign side effects of the explicit refspec, noted in review: the fetch
    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.
  • PR Initialize config-managed submodules at their branch tip #802 merged while this review feedback was being addressed, so these fixes
    come as their own PR rather than as commits on that branch.

🤖 Generated with Claude Code

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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T16:07:33.680383Z a08157d New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@alexander-yevsyukov alexander-yevsyukov self-assigned this Oct 19, 2026
@alexander-yevsyukov
alexander-yevsyukov requested a balanced review from Copilot October 19, 2026 15:34
@alexander-yevsyukov alexander-yevsyukov moved this to 🏗 In progress in v2.0 Oct 19, 2026
@alexander-yevsyukov alexander-yevsyukov moved this from 🏗 In progress to In Review in v2.0 Oct 19, 2026
Copilot AI previously approved these changes Oct 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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>
@alexander-yevsyukov alexander-yevsyukov changed the title Resolve the tracked branch exactly and fetch it by explicit refspec Resolve the tracked branch exactly and fetch it by explicit refspec, in both scripts Oct 19, 2026
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>
@alexander-yevsyukov

Copy link
Copy Markdown
Contributor Author

Re-requesting review: this PR has grown since the last pass. It now also gives
./config/pull the same explicit fetch destination and show-ref-guarded
checkout, because it carried both of the same faults — and there the tag-shadow
case detached HEAD with no warning at all. New commits since the last review:

  • ba4762c7 — the pull change, plus a reworded parity comment in init-submodules
  • a1b46b5f — corrects ba4762c7's claim that a diverged local branch is silent
    (ahead-only is silent; genuinely diverged fails --ff-only and warns)

The earlier approval predates both, and pull floats submodules that are
already checked out and in use, so the local-work paths are the part most worth
a second look.

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread pull
…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

No unresolved findings remain, and reported syntax and control-flow checks support the focused changes.

No open issues 🎉

Review effort: Balanced

@alexander-yevsyukov
alexander-yevsyukov merged commit 3516efc into master Oct 19, 2026
3 checks passed
@alexander-yevsyukov
alexander-yevsyukov deleted the init-submodules-exact-branch-resolution branch October 19, 2026 16:30
@github-project-automation github-project-automation Bot moved this from In Review to ✅ Done in v2.0 Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

2 participants