77 "io"
88 "log/slog"
99 "os"
10+ "os/exec"
11+ "strings"
1012
1113 git "github.com/go-git/go-git/v6"
1214 "github.com/go-git/go-git/v6/plumbing"
@@ -35,6 +37,17 @@ var opfPrePushProgressWriter io.Writer = os.Stderr
3537// - push_sessions: false to disable automatic pushing of checkpoints
3638// - checkpoint_remote: {"provider": "github", "repo": "org/repo"} to push to a separate repo
3739func (s * ManualCommitStrategy ) PrePush (ctx context.Context , remote string ) error {
40+ return s .prePush (ctx , remote , false )
41+ }
42+
43+ // PrePushFromGitHook handles a push initiated by Git's pre-push hook. Unlike
44+ // direct callers, it protects an empty user remote from receiving checkpoint
45+ // metadata before the user's first normal branch is published.
46+ func (s * ManualCommitStrategy ) PrePushFromGitHook (ctx context.Context , remote string ) error {
47+ return s .prePush (ctx , remote , true )
48+ }
49+
50+ func (s * ManualCommitStrategy ) prePush (ctx context.Context , remote string , protectFirstUserBranch bool ) error {
3851 // Load settings once for remote resolution and push_sessions check.
3952 // Spanned because checkpoint-remote resolution can perform a one-time
4053 // network fetch of the metadata branch (fetchMetadataBranchIfMissing),
@@ -48,12 +61,20 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error
4861 }
4962
5063 // git-refs primary: push the per-checkpoint refs recorded in the push queue
51- // instead of the single v1 branch. (A configured git-branch mirror's v1 ref
52- // is not pushed here yet — mirror push for downgrade safety is a later step.)
64+ // instead of the single v1 branch. Those refs live under refs/entire/, not
65+ // refs/heads/, so a forge can never pick them as a repository's default
66+ // branch — the empty-remote guard below is unnecessary for this backend.
67+ // (A configured git-branch mirror's v1 ref is not pushed here yet — mirror
68+ // push for downgrade safety is a later step.)
5369 if cpCfg , _ := settings .LoadCheckpointsConfig (ctx ); checkpoint .PrimaryIsRefs (cpCfg ) { //nolint:errcheck // fail-soft: a bad checkpoints block already surfaces via Open; default to no refs push
5470 return s .prePushCheckpointRefs (ctx , ps )
5571 }
5672
73+ // git-branch primary: entire/checkpoints/v1 is a real refs/heads branch, so
74+ // on an otherwise-empty remote a forge like GitHub would select it as the
75+ // default. Defer publication until the user's own branch exists there.
76+ deferAutomaticCheckpointPush := protectFirstUserBranch && deferCheckpointPushOnEmptyRemote (ctx , ps )
77+
5778 refs := checkpoint .ResolveRefs (ctx )
5879 repo , repoErr := OpenRepository (ctx )
5980 if repoErr != nil {
@@ -116,6 +137,15 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error
116137 }
117138 }
118139
140+ if deferAutomaticCheckpointPush {
141+ // Do this only after OPF has had a chance to rewrite v1: the outer
142+ // user push may explicitly include the metadata branch.
143+ logging .Info (ctx , "automatic checkpoint push deferred until the remote has a branch" ,
144+ slog .String ("remote" , ps .remote ),
145+ )
146+ return nil
147+ }
148+
119149 // Thread the span's context into the push so the network push and any
120150 // fetch+rebase recovery nest beneath it as child steps in the perf trace.
121151 pushCtx , pushCheckpointsSpan := perf .Start (ctx , "push_checkpoint_refs" )
@@ -132,6 +162,79 @@ func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error
132162 return nil
133163}
134164
165+ // deferCheckpointPushOnEmptyRemote reports whether publication of the git-branch
166+ // v1 metadata should be held back because the push remote may be brand new.
167+ //
168+ // Hosting providers such as GitHub make the first branch pushed to an empty
169+ // repository its default, so the pre-push hook must not publish
170+ // entire/checkpoints/v1 ahead of the user's own first branch. The check is
171+ // purely local: if a remote-tracking ref for this remote already exists
172+ // (refs/remotes/<remote>/*), the remote has been fetched from or pushed to
173+ // before and therefore already has at least one branch, so publishing cannot
174+ // make our metadata the default. Otherwise defer — git records a
175+ // remote-tracking ref after the first successful push, so the deferred metadata
176+ // publishes on the next push.
177+ //
178+ // It deliberately performs no ls-remote/fetch. A network round trip on the
179+ // pre-push path can trigger an SSH security-key touch prompt (and doing so per
180+ // push URL would multiply those prompts), which is a poor pre-push UX. This is
181+ // also why it uses only the remote git handed the hook rather than resolving
182+ // every configured push URL.
183+ //
184+ // A separate checkpoint remote is exempt: it is a dedicated metadata store, not
185+ // the repository the user pushes to.
186+ func deferCheckpointPushOnEmptyRemote (ctx context.Context , ps pushSettings ) bool {
187+ if ps .hasCheckpointURL () {
188+ return false
189+ }
190+
191+ // The hazard only arises for a configured remote (the `git remote add
192+ // origin …` then first-push flow). Pushing straight to a bare URL hands that
193+ // URL to the hook as the remote arg, and git never records a
194+ // refs/remotes/<url>/* tracking ref for it — so a tracking-ref check would
195+ // defer the metadata forever. Publish for a non-configured (URL) target
196+ // rather than strand it; the first-branch scenario always uses a named
197+ // remote.
198+ if ! isConfiguredRemote (ctx , ps .remote ) {
199+ return false
200+ }
201+
202+ // Known limitation, accepted for the no-network design: a tracking ref left
203+ // over from before a remote was deleted and recreated empty under the same
204+ // URL reads as "established", so v1 would publish to the now-empty remote.
205+ // Detecting that requires asking the remote — the network round trip we
206+ // deliberately avoid here. The scenario is rare and its default branch is
207+ // recoverable by resetting it on the forge.
208+ return ! remoteHasTrackingRefs (ctx , ps .remote )
209+ }
210+
211+ // isConfiguredRemote reports whether name is a configured git remote, as
212+ // opposed to a bare URL that git passes through verbatim when a push targets a
213+ // URL directly. Local and best-effort (reads config, no network); any error is
214+ // treated as "not a configured remote".
215+ func isConfiguredRemote (ctx context.Context , name string ) bool {
216+ if name == "" {
217+ return false
218+ }
219+ return exec .CommandContext (ctx , "git" , "remote" , "get-url" , name ).Run () == nil
220+ }
221+
222+ // remoteHasTrackingRefs reports whether any refs/remotes/<remote>/* ref exists
223+ // locally. Its presence means the remote has been fetched from or pushed to
224+ // before and so already has at least one branch. Local-only and best-effort:
225+ // any error is treated as "no tracking refs" so the caller fails safe (defers).
226+ func remoteHasTrackingRefs (ctx context.Context , remote string ) bool {
227+ if remote == "" {
228+ return false
229+ }
230+ cmd := exec .CommandContext (ctx , "git" , "for-each-ref" , "--count=1" , "refs/remotes/" + remote + "/" )
231+ out , err := cmd .Output ()
232+ if err != nil {
233+ return false
234+ }
235+ return strings .TrimSpace (string (out )) != ""
236+ }
237+
135238// prePushCheckpointRefs drains the per-checkpoint push queue and batch-pushes the
136239// recorded refs fast-forward-only (git-refs primary; never a force push — a
137240// diverged ref is recovered via fetch+replay). Transient push failures are logged and
0 commit comments