Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions cmd/entire/cli/integration_test/checkpoint_sync_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,46 @@ func TestCheckpointSyncRemote_TrackedPushCapturesElection(t *testing.T) {
})
}

// TestCheckpointSyncRemote_PushURLOnlyRemoteCannotCaptureElection verifies
// that capture and election use the same fetch-URL eligibility rule. Git treats
// a pushurl-only entry as a configured push target, but checkpoint sync cannot
// read or reconcile from it, so a push there must carry no checkpoint data and
// must not make the destination sticky.
func TestCheckpointSyncRemote_PushURLOnlyRemoteCannotCaptureElection(t *testing.T) {
t.Parallel()
ForEachBackend(t, func(t *testing.T, backend string) {
env := NewFeatureBranchEnv(t)
env.CheckpointStore = backend

bareOrigin := env.SetupBareRemote()
barePushOnly := env.SetupNamedBareRemote("pushonly")
testutil.RunGit(t, env.RepoDir, "config", "remote.pushonly.pushurl", barePushOnly)
testutil.RunGit(t, env.RepoDir, "config", "--unset-all", "remote.pushonly.url")
env.setGitConfigBaseline()

checkpointID := createCheckpointedCommit(t, env, "Add capture guard", "guard.go", "package guard", "Add capture guard")

env.RunPrePush("pushonly")
if env.CheckpointsPresentOnRemote(barePushOnly) {
t.Error("a pushurl-only remote must not receive checkpoint data")
}
if got := capturedSyncRemotesOnDisk(t, env); got != nil {
t.Errorf("a pushurl-only remote must not capture the election, got %v", got)
}
if env.usingGitRefs() && queuedCheckpointRefCount(t, env) == 0 {
t.Error("push queue should be preserved after the ineligible push")
}

env.RunPrePush("origin")
if !env.CheckpointExistsOnRemote(bareOrigin, checkpointID) {
t.Errorf("checkpoint %s should remain available to the elected origin", checkpointID)
}
if env.usingGitRefs() && queuedCheckpointRefCount(t, env) != 0 {
t.Error("push queue should drain after pushing to origin")
}
})
}

// TestCheckpointSyncRemote_DeferredPushDoesNotCaptureElection covers the
// gate-to-delivery gap on the most likely first push there is: the user adds a
// brand-new empty fork and pushes their branch to it for the first time. That
Expand Down
4 changes: 2 additions & 2 deletions cmd/entire/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,8 @@ type checkpointSyncInfo struct {
func computeCheckpointSyncInfo(ctx context.Context, s *EntireSettings) checkpointSyncInfo {
elected, err := strategy.ResolveCheckpointSyncRemote(ctx)
if err != nil {
// Fail-closed: checkpoint_push_remote names a remote that does not
// exist. The pre-push gate is silently skipping checkpoint sync, so
// Fail-closed: election could not confirm a usable checkpoint sync
// remote. The pre-push gate is silently skipping checkpoint sync, so
// status is the user's signal.
// Accepted divergence: if a structured checkpoint_remote is also
// configured, the gate's dedicated exemption may still sync checkpoint
Expand Down
52 changes: 36 additions & 16 deletions cmd/entire/cli/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -2366,24 +2367,43 @@ func TestRunStatus_CheckpointSyncDestination_CapturedAnnotated(t *testing.T) {

func TestRunStatus_CheckpointSyncFailClosed(t *testing.T) {
testutil.IsolateGitConfigEnv(t)
setupTestRepo(t)
writeSettings(t, `{"enabled": true, "strategy_options": {"checkpoint_push_remote": "gone"}}`)
testutil.AddRemote(t, ".", "origin", "https://example.com/origin.git")

var stdout bytes.Buffer
if err := runStatus(context.Background(), &stdout, false, false); err != nil {
t.Fatalf("runStatus() error = %v", err)
}
for _, tt := range []struct {
name string
remote string
pushurlOnly bool
wantReason string
}{
{name: "missing remote", remote: "gone"},
{name: "pushurl-only remote", remote: "pushonly", pushurlOnly: true, wantReason: "fetch URL"},
} {
t.Run(tt.name, func(t *testing.T) {
setupTestRepo(t)
writeSettings(t, fmt.Sprintf(`{"enabled": true, "strategy_options": {"checkpoint_push_remote": %q}}`, tt.remote))
testutil.AddRemote(t, ".", "origin", "https://example.com/origin.git")
if tt.pushurlOnly {
testutil.RunGit(t, ".", "config", "remote."+tt.remote+".pushurl", "https://example.com/pushonly.git")
}

out := stdout.String()
if !strings.Contains(out, "Checkpoints NOT syncing:") {
t.Errorf("expected fail-closed warning line, got:\n%s", out)
}
if !strings.Contains(out, `"gone"`) {
t.Errorf("fail-closed line should name the misconfigured remote, got:\n%s", out)
}
if strings.Contains(out, "Checkpoints sync to:") {
t.Errorf("fail-closed status must not also print a destination line, got:\n%s", out)
var stdout bytes.Buffer
if err := runStatus(context.Background(), &stdout, false, false); err != nil {
t.Fatalf("runStatus() error = %v", err)
}

out := stdout.String()
if !strings.Contains(out, "Checkpoints NOT syncing:") {
t.Errorf("expected fail-closed warning line, got:\n%s", out)
}
if !strings.Contains(out, fmt.Sprintf("%q", tt.remote)) {
t.Errorf("fail-closed line should name remote %q, got:\n%s", tt.remote, out)
}
if tt.wantReason != "" && !strings.Contains(out, tt.wantReason) {
t.Errorf("fail-closed line should contain %q, got:\n%s", tt.wantReason, out)
}
if strings.Contains(out, "Checkpoints sync to:") {
t.Errorf("fail-closed status must not also print a destination line, got:\n%s", out)
}
})
}
}

Expand Down
3 changes: 2 additions & 1 deletion cmd/entire/cli/strategy/checkpoint_sync_capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"log/slog"
"os"
"os/exec"
"slices"
"strings"

"github.com/entireio/cli/cmd/entire/cli/gitdir"
Expand Down Expand Up @@ -102,7 +103,7 @@ func saveCapturedSyncRemote(ctx context.Context, name string) error {
// the write idempotent — "first capture sticks" is decided when the state lands,
// not when it was proposed.
func pendingCaptureCheckpointSyncRemote(ctx context.Context, pushRemote string) bool {
if !isConfiguredRemote(ctx, pushRemote) {
if !slices.Contains(configuredRemotesInConfigOrder(ctx), pushRemote) {
return false
}
root, err := capturedSyncRemotesRoot(ctx)
Expand Down
50 changes: 26 additions & 24 deletions cmd/entire/cli/strategy/checkpoint_sync_remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ type CheckpointSyncRemote struct {

// ResolveCheckpointSyncRemote elects the one configured git remote that
// checkpoint data syncs to. Pure local lookup — no network. Precedence:
// checkpoint_push_remote setting (fail-closed if the named remote does not
// exist), then the captured election (evidence-elected by a past push that
// agreed with the branch's declared push destination; fail-soft if that
// remote is gone), then "origin", then the sole remote, then the first remote
// in .git/config order. It knows nothing about the checkpoint_remote URL
// feature; callers exempt that case themselves.
// checkpoint_push_remote setting (fail-closed if the named remote has no
// fetch URL), then the captured election (evidence-elected by a past push that
// agreed with the branch's declared push destination; fail-soft if that remote
// is no longer fetchable), then "origin", then the sole remote, then the first
// remote in .git/config order. It knows nothing about the checkpoint_remote
// URL feature; callers exempt that case themselves.
//
// Deliberately NOT keyed on the branch's tracking config alone
// (branch.<name>.pushRemote / remote.pushDefault / branch.<name>.remote).
Expand Down Expand Up @@ -79,35 +79,37 @@ func ResolveCheckpointSyncRemote(ctx context.Context) (CheckpointSyncRemote, err
if err != nil {
return CheckpointSyncRemote{}, fmt.Errorf("cannot read settings to resolve the checkpoint sync remote: %w", err)
}
// Every tier elects from the same fetchable set. `git remote get-url`
// accepts a pushurl-only entry even though reads and reconciliation cannot.
fetchRemotes := configuredRemotesInConfigOrder(ctx)
if name := s.GetCheckpointPushRemote(); name != "" {
if !isConfiguredRemote(ctx, name) {
if !slices.Contains(fetchRemotes, name) {
return CheckpointSyncRemote{}, fmt.Errorf(
"checkpoint_push_remote %q is not a configured git remote; checkpoint sync disabled until fixed", name)
"checkpoint_push_remote %q has no configured fetch URL; checkpoint sync disabled until fixed", name)
}
return CheckpointSyncRemote{Name: name, Source: SyncRemoteSourceConfig}, nil
}

// Captured tier: fail-soft, unlike the explicit setting above — capture
// is automatic state, so a captured remote that was since renamed or
// removed falls through to the default tiers instead of disabling sync.
// is automatic state, so a captured remote that is no longer fetchable
// falls through to the default tiers instead of disabling sync.
for _, name := range loadCapturedSyncRemotes(ctx) {
if isConfiguredRemote(ctx, name) {
if slices.Contains(fetchRemotes, name) {
return CheckpointSyncRemote{Name: name, Source: SyncRemoteSourceObserved}, nil
}
logging.Debug(ctx, "captured checkpoint sync remote is not configured; falling through",
logging.Debug(ctx, "captured checkpoint sync remote has no configured fetch URL; falling through",
slog.String("remote", name))
}

remotes := configuredRemotesInConfigOrder(ctx)
switch {
case len(remotes) == 0:
case len(fetchRemotes) == 0:
return CheckpointSyncRemote{}, nil
case slices.Contains(remotes, "origin"):
case slices.Contains(fetchRemotes, "origin"):
return CheckpointSyncRemote{Name: "origin", Source: SyncRemoteSourceDefault}, nil
case len(remotes) == 1:
return CheckpointSyncRemote{Name: remotes[0], Source: SyncRemoteSourceSole}, nil
case len(fetchRemotes) == 1:
return CheckpointSyncRemote{Name: fetchRemotes[0], Source: SyncRemoteSourceSole}, nil
default:
return CheckpointSyncRemote{Name: remotes[0], Source: SyncRemoteSourceFirst}, nil
return CheckpointSyncRemote{Name: fetchRemotes[0], Source: SyncRemoteSourceFirst}, nil
}
}

Expand Down Expand Up @@ -147,17 +149,17 @@ func checkpointSyncAllowedForRemote(ctx context.Context, pushRemote, pendingCapt
// until the elected remote happens to be pushed.
//
// Stays quiet unless every condition holds: the push target is a configured
// remote (checkpoint_push_remote takes a remote name, so a raw-URL push has
// no actionable suggestion), the election succeeded AND was automatic (an
// explicit checkpoint_push_remote is a decision already made, and the
// fail-closed misconfigured case logs a warning through the gate itself), and
// checkpoints are actually waiting. Fully local — no network.
// remote with a fetch URL (checkpoint_push_remote must name one), the election
// succeeded AND was automatic (an explicit checkpoint_push_remote is a
// decision already made, and the fail-closed misconfigured case logs a warning
// through the gate itself), and checkpoints are actually waiting. Fully local
// — no network.
//
// The hint names .entire/settings.local.json: a remote name is a per-clone
// fact, and committing it to the tracked settings.json would fail-close
// checkpoint sync for every teammate whose clone lacks that remote name.
func hintGatedCheckpointSync(ctx context.Context, pushRemote string) {
if !isConfiguredRemote(ctx, pushRemote) {
if !slices.Contains(configuredRemotesInConfigOrder(ctx), pushRemote) {
return
}
syncRemote, err := ResolveCheckpointSyncRemote(ctx)
Expand Down
76 changes: 62 additions & 14 deletions cmd/entire/cli/strategy/checkpoint_sync_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,38 @@ func TestResolveCheckpointSyncRemote_ConfigSetting(t *testing.T) {
}

// Not parallel: uses t.Chdir()
func TestResolveCheckpointSyncRemote_ConfigSettingMissingRemote_FailsClosed(t *testing.T) {
func TestResolveCheckpointSyncRemote_ConfigSettingInvalidRemote_FailsClosed(t *testing.T) {
testutil.IsolateGitConfigEnv(t)
ctx := context.Background()
tmpDir := t.TempDir()
testutil.InitRepo(t, tmpDir)
testutil.WriteFile(t, tmpDir, "f.txt", "init")
testutil.GitAdd(t, tmpDir, "f.txt")
testutil.GitCommit(t, tmpDir, "init")

testutil.AddRemote(t, tmpDir, "origin", "https://example.com/origin.git")
testutil.WriteCheckpointPushRemoteSetting(t, tmpDir, "gone")

t.Chdir(tmpDir)
for _, tt := range []struct {
name string
remote string
pushURL string
}{
{name: "missing remote", remote: "gone"},
{name: "pushurl-only remote", remote: "pushonly", pushURL: "https://example.com/pushonly.git"},
} {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
testutil.InitRepo(t, tmpDir)
testutil.WriteFile(t, tmpDir, "f.txt", "init")
testutil.GitAdd(t, tmpDir, "f.txt")
testutil.GitCommit(t, tmpDir, "init")
testutil.AddRemote(t, tmpDir, "origin", "https://example.com/origin.git")
if tt.pushURL != "" {
testutil.RunGit(t, tmpDir, "config", "remote."+tt.remote+".pushurl", tt.pushURL)
}
testutil.WriteCheckpointPushRemoteSetting(t, tmpDir, tt.remote)
t.Chdir(tmpDir)

got, err := ResolveCheckpointSyncRemote(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "gone")
assert.Empty(t, got.Name)
got, err := ResolveCheckpointSyncRemote(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.remote)
assert.Contains(t, err.Error(), "fetch URL")
assert.Empty(t, got.Name)
})
}
}

// Not parallel: uses t.Chdir()
Expand Down Expand Up @@ -512,6 +526,18 @@ func TestHintGatedCheckpointSync(t *testing.T) {
assert.Empty(t, buf.String(), "checkpoint_push_remote takes a remote name; a URL push has no actionable hint")
})

t.Run("pushurl-only remote stays silent", func(t *testing.T) {
dir := initHintRepo(t, false)
testutil.RunGit(t, dir, "config", "remote.pushonly.pushurl", "https://example.com/pushonly.git")
setGitConfig(t, dir, "remote.pushDefault", "pushonly")
t.Chdir(dir)
buf := captureStderrWriter(t)

hintGatedCheckpointSync(ctx, "pushonly")

assert.Empty(t, buf.String(), "the hint must not recommend a remote that checkpoint sync cannot read from")
})

t.Run("failed election stays silent", func(t *testing.T) {
dir := initHintRepo(t, true)
testutil.WriteCheckpointPushRemoteSetting(t, dir, "gone")
Expand Down Expand Up @@ -569,6 +595,17 @@ func TestResolveCheckpointSyncRemote_CapturedTier(t *testing.T) {
assert.Equal(t, CheckpointSyncRemote{Name: "origin", Source: SyncRemoteSourceDefault}, got)
})

t.Run("captured pushurl-only remote falls through to origin", func(t *testing.T) {
dir := newCaptureTestRepo(t)
testutil.RunGit(t, dir, "config", "remote.pushonly.pushurl", "https://example.com/pushonly.git")
t.Chdir(dir)
require.NoError(t, saveCapturedSyncRemote(ctx, "pushonly"))

got, err := ResolveCheckpointSyncRemote(ctx)
require.NoError(t, err)
assert.Equal(t, CheckpointSyncRemote{Name: "origin", Source: SyncRemoteSourceDefault}, got)
})

t.Run("corrupt capture state falls through to origin", func(t *testing.T) {
dir := newCaptureTestRepo(t)
t.Chdir(dir)
Expand Down Expand Up @@ -712,6 +749,17 @@ func TestCaptureCheckpointSyncRemote(t *testing.T) {
assert.Equal(t, []string{"fork"}, loadCapturedSyncRemotes(ctx))
})

t.Run("pushurl-only remote cannot capture", func(t *testing.T) {
dir := newCaptureTestRepo(t)
testutil.RunGit(t, dir, "config", "remote.pushonly.pushurl", "https://example.com/pushonly.git")
setGitConfig(t, dir, "remote.pushDefault", "pushonly")
t.Chdir(dir)

assert.False(t, pendingCaptureCheckpointSyncRemote(ctx, "pushonly"),
"capture must use the same fetch-URL eligibility rule as election")
assert.Empty(t, loadCapturedSyncRemotes(ctx))
})

t.Run("raw URL push never captures", func(t *testing.T) {
dir := newCaptureTestRepo(t)
setGitConfig(t, dir, "branch."+currentBranchName(t, dir)+".remote", "fork")
Expand Down
Loading