Skip to content

Commit 3b6957b

Browse files
authored
Merge pull request #1744 from entireio/fix/1743-defer-checkpoint-push-empty-remote
fix(strategy): defer checkpoint push until a normal remote branch exists (#1743)
2 parents 62f8779 + f12a526 commit 3b6957b

7 files changed

Lines changed: 273 additions & 12 deletions

File tree

cmd/entire/cli/hooks_git_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ func newHooksGitPrePushCmd() *cobra.Command {
326326
defer g.span.End()
327327
g.logInvoked(slog.String("remote", remote))
328328

329-
hookErr := g.strategy.PrePush(g.ctx, remote)
329+
hookErr := g.strategy.PrePushFromGitHook(g.ctx, remote)
330330
g.logCompleted(hookErr)
331331

332332
// Propagate the error so the hook script exits non-zero and

cmd/entire/cli/integration_test/real_hook_push_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,55 @@ func TestGitPushWithHooks_SyncsCheckpointsToRemote(t *testing.T) {
4343
}
4444
})
4545
}
46+
47+
// TestGitPushWithHooks_DefersCheckpointsUntilFirstUserBranchExists ensures the
48+
// user's own branch — not Entire metadata — is the first ref on a fresh remote.
49+
//
50+
// On the git-branch backend, entire/checkpoints/v1 is a real branch a forge
51+
// could pick as the repository default, so its push is deferred until the
52+
// user's branch has landed. On the git-refs backend, checkpoints live under
53+
// refs/entire/*, which a forge cannot select as a default branch, so there is
54+
// no hazard and they publish on the first push.
55+
func TestGitPushWithHooks_DefersCheckpointsUntilFirstUserBranchExists(t *testing.T) {
56+
t.Parallel()
57+
58+
ForEachBackend(t, func(t *testing.T, backend string) {
59+
env := NewFeatureBranchEnv(t)
60+
env.CheckpointStore = backend
61+
62+
bareDir := env.SetupEmptyNamedBareRemote("origin")
63+
branch := env.GetCurrentBranch()
64+
checkpointID := createCheckpointedCommit(t, env, "Add auth module", "auth.go", "package auth", "Add auth module")
65+
if checkpointID == "" {
66+
t.Fatal("should have a checkpoint ID after condensation")
67+
}
68+
69+
// The first push must land the user's branch on the empty remote.
70+
env.GitPushWithHooks("origin", "HEAD")
71+
if !env.BranchExistsOnRemote(bareDir, branch) {
72+
t.Fatalf("[%s] first user branch %q should be on remote", backend, branch)
73+
}
74+
75+
if backend == StoreGitRefs {
76+
// refs/entire/* can't become a default branch → no deferral.
77+
if !env.CheckpointExistsOnRemote(bareDir, checkpointID) {
78+
t.Fatalf("[git-refs] checkpoint %s should publish on the first push (no default-branch hazard)", checkpointID)
79+
}
80+
return
81+
}
82+
83+
// git-branch: the v1 branch must be withheld until the user branch exists.
84+
if env.CheckpointsPresentOnRemote(bareDir) {
85+
t.Fatalf("[git-branch] checkpoints must be deferred until after the first user branch push")
86+
}
87+
88+
// The first push created a remote-tracking ref, so a later push publishes.
89+
env.WriteFile("later.go", "package later")
90+
env.GitAdd("later.go")
91+
env.GitCommit("Later user commit")
92+
env.GitPushWithHooks("origin", "HEAD")
93+
if !env.CheckpointExistsOnRemote(bareDir, checkpointID) {
94+
t.Fatalf("[git-branch] deferred checkpoint %s should be published on a later push", checkpointID)
95+
}
96+
})
97+
}

cmd/entire/cli/integration_test/testenv.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,6 +1804,25 @@ func (env *TestEnv) SetupBareRemote() string {
18041804
// multiple remotes.
18051805
func (env *TestEnv) SetupNamedBareRemote(remoteName string) string {
18061806
env.T.Helper()
1807+
bareDir := env.SetupEmptyNamedBareRemote(remoteName)
1808+
1809+
// Push HEAD to the remote.
1810+
cmd := exec.CommandContext(env.T.Context(), "git", "push", "--no-verify", "-u", remoteName, "HEAD")
1811+
cmd.Dir = env.RepoDir
1812+
cmd.Env = testutil.GitIsolatedEnv()
1813+
if output, err := cmd.CombinedOutput(); err != nil {
1814+
env.T.Fatalf("failed to push to %s: %v\n%s", remoteName, err, output)
1815+
}
1816+
1817+
env.setGitConfigBaseline()
1818+
1819+
return bareDir
1820+
}
1821+
1822+
// SetupEmptyNamedBareRemote creates a bare git repository and adds it as a
1823+
// remote without pushing a branch. Use this to exercise first-push behavior.
1824+
func (env *TestEnv) SetupEmptyNamedBareRemote(remoteName string) string {
1825+
env.T.Helper()
18071826

18081827
ctx := env.T.Context()
18091828

@@ -1828,14 +1847,6 @@ func (env *TestEnv) SetupNamedBareRemote(remoteName string) string {
18281847
env.T.Fatalf("failed to add remote %s: %v\n%s", remoteName, err, output)
18291848
}
18301849

1831-
// Push HEAD to the remote
1832-
cmd = exec.CommandContext(ctx, "git", "push", "--no-verify", "-u", remoteName, "HEAD")
1833-
cmd.Dir = env.RepoDir
1834-
cmd.Env = testutil.GitIsolatedEnv()
1835-
if output, err := cmd.CombinedOutput(); err != nil {
1836-
env.T.Fatalf("failed to push to %s: %v\n%s", remoteName, err, output)
1837-
}
1838-
18391850
env.setGitConfigBaseline()
18401851

18411852
return bareDir

cmd/entire/cli/strategy/manual_commit_opf_rewrite_test.go

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ func configureFakeOPF(t *testing.T, rt testOPFRuntime) {
110110
// setupV1Repo creates a repo + one v1 checkpoint with "PERSONABC" in
111111
// both the transcript and prompt. Returns the repo and the v1 tip.
112112
func setupV1Repo(t *testing.T) (*git.Repository, plumbing.Hash) {
113+
_, repo, tip := setupV1RepoInDir(t)
114+
return repo, tip
115+
}
116+
117+
func setupV1RepoInDir(t *testing.T) (string, *git.Repository, plumbing.Hash) {
113118
t.Helper()
114119
tempDir := t.TempDir()
115120
testutil.InitRepo(t, tempDir)
@@ -127,7 +132,7 @@ func setupV1Repo(t *testing.T) (*git.Repository, plumbing.Hash) {
127132
require.NoError(t, err)
128133

129134
tip := addV1Checkpoint(t, repo, "a1b2c3d4e5f6", "test-session", "Hello, PERSONABC asked", "Look up PERSONABC")
130-
return repo, tip
135+
return tempDir, repo, tip
131136
}
132137

133138
func addV1Checkpoint(t *testing.T, repo *git.Repository, cpIDString, sessionID, transcript, prompt string) plumbing.Hash {
@@ -231,6 +236,34 @@ func TestRewriteUnpushedV1WithOPF_HappyPath_RewritesAndTagsApplied(t *testing.T)
231236
}))
232237
}
233238

239+
func TestPrePushFromGitHook_DeferralStillRunsOPF(t *testing.T) {
240+
fake := &fakeOPFForRewrite{}
241+
configureFakeOPF(t, fake)
242+
243+
dir, repo, originalTip := setupV1RepoInDir(t)
244+
remoteDir := filepath.Join(t.TempDir(), "origin.git")
245+
_, err := git.PlainInit(remoteDir, true)
246+
require.NoError(t, err)
247+
_, err = repo.CreateRemote(&gitconfig.RemoteConfig{Name: "origin", URLs: []string{remoteDir}})
248+
require.NoError(t, err)
249+
250+
t.Chdir(dir)
251+
paths.ClearWorktreeRootCache()
252+
t.Cleanup(paths.ClearWorktreeRootCache)
253+
254+
// The empty remote defers Entire's automatic metadata push. The OPF rewrite
255+
// still must run because the user's outer git push may include v1 directly.
256+
require.NoError(t, NewManualCommitStrategy().PrePushFromGitHook(t.Context(), "origin"))
257+
258+
ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
259+
require.NoError(t, err)
260+
require.NotEqual(t, originalTip, ref.Hash(), "OPF rewrite must advance the local v1 ref before deferral")
261+
commit, err := repo.CommitObject(ref.Hash())
262+
require.NoError(t, err)
263+
require.True(t, trailers.HasOPFApplied(commit.Message))
264+
require.Equal(t, 1, fake.batchCallCount())
265+
}
266+
234267
func TestRewriteUnpushedV1WithOPF_MultiCommitTipCarriesPriorRedactedShards(t *testing.T) {
235268
configureFakeOPF(t, &fakeOPFForRewrite{})
236269
repo, _ := setupV1Repo(t)

cmd/entire/cli/strategy/manual_commit_push.go

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
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
3739
func (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
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package strategy
2+
3+
import (
4+
"context"
5+
"os/exec"
6+
"testing"
7+
8+
"github.com/entireio/cli/cmd/entire/cli/testutil"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// TestDeferCheckpointPushOnEmptyRemote_UsesLocalTrackingRefs verifies the guard
14+
// decides purely from local remote-tracking refs, with no network access: a
15+
// remote with no refs/remotes/<remote>/* is treated as possibly-empty (defer),
16+
// and one with any tracking ref is treated as established (publish).
17+
func TestDeferCheckpointPushOnEmptyRemote_UsesLocalTrackingRefs(t *testing.T) {
18+
// No t.Parallel: uses t.Chdir.
19+
dir := t.TempDir()
20+
testutil.InitRepo(t, dir)
21+
22+
run := func(args ...string) {
23+
t.Helper()
24+
cmd := exec.CommandContext(t.Context(), "git", args...)
25+
cmd.Dir = dir
26+
require.NoError(t, cmd.Run(), "git %v", args)
27+
}
28+
run("commit", "--allow-empty", "-m", "init")
29+
// A deliberately unreachable URL: the guard must never dial it.
30+
run("remote", "add", "origin", "https://example.invalid/repo.git")
31+
32+
t.Chdir(dir)
33+
ctx := context.Background()
34+
ps := pushSettings{remote: "origin"}
35+
36+
// No remote-tracking refs yet → possibly a brand-new remote → defer.
37+
require.True(t, deferCheckpointPushOnEmptyRemote(ctx, ps),
38+
"a remote with no tracking refs must defer")
39+
40+
// A push straight to a bare URL is not a configured remote; git never records
41+
// a tracking ref for it, so the guard must publish rather than defer forever.
42+
require.False(t,
43+
deferCheckpointPushOnEmptyRemote(ctx, pushSettings{remote: "https://example.invalid/repo.git"}),
44+
"a bare-URL push target must not defer")
45+
46+
// git records a remote-tracking ref after the first successful push; simulate
47+
// that locally (no network). The remote is now established → publish.
48+
run("update-ref", "refs/remotes/origin/main", "HEAD")
49+
require.False(t, deferCheckpointPushOnEmptyRemote(ctx, ps),
50+
"a remote with a tracking ref must not defer")
51+
52+
// A configured separate checkpoint remote is always exempt.
53+
require.False(t,
54+
deferCheckpointPushOnEmptyRemote(ctx, pushSettings{remote: "origin", checkpointURL: "https://example.invalid/cp.git"}),
55+
"a dedicated checkpoint remote is exempt from the guard")
56+
}

e2e/tests/alternates_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ func TestAlternates_RelativeObjectAlternate_CheckpointSync(t *testing.T) {
101101
testutil.Git(t, work, "update-ref", "refs/heads/entire/checkpoints/v1", k2)
102102
testutil.Git(t, work, "remote", "add", "origin", originBare)
103103

104+
// The remote already carries the v1 branch (seeded above). In a real repo
105+
// that means we hold a remote-tracking ref for it, so record one here: the
106+
// first-user-branch guard treats a remote with tracking refs as established
107+
// (non-empty) and runs the sync instead of deferring the push.
108+
testutil.Git(t, work, "update-ref", "refs/remotes/origin/entire/checkpoints/v1", r1)
109+
104110
// Drive the real pre-push hook: non-ff vs the remote forces the sync/rebase
105111
// path that reads the alternate-resident checkpoint commits via go-git.
106112
cmd := exec.Command(entire.BinPath(), "hooks", "git", "pre-push", "origin")

0 commit comments

Comments
 (0)