diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 99a695f6d..b030eed9c 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -145,6 +145,18 @@ func LoadFromFile(filePath string) (*EntireSettings, error) { return loadFromFile(filePath) } +// LoadFromBytes parses settings from raw JSON bytes without merging local overrides. +// Use this when you have settings content from a non-file source (e.g., git show). +func LoadFromBytes(data []byte) (*EntireSettings, error) { + s := &EntireSettings{Enabled: true} + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(s); err != nil { + return nil, fmt.Errorf("parsing settings: %w", err) + } + return s, nil +} + // loadFromFile loads settings from a specific file path. // Returns default settings if the file doesn't exist. func loadFromFile(filePath string) (*EntireSettings, error) { diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index c304ad09e..1f464d2cc 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -8,8 +8,11 @@ import ( "os" "os/exec" "strings" + "sync" "time" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" @@ -73,6 +76,7 @@ func doPushBranch(ctx context.Context, target, branchName string) error { // Try pushing first if err := tryPushSessionsCommon(ctx, target, branchName); err == nil { stop(" done") + printSettingsCommitHint(ctx, target) return nil } stop("") @@ -99,6 +103,7 @@ func doPushBranch(ctx context.Context, target, branchName string) error { printCheckpointRemoteHint(target) } else { stop(" done") + printSettingsCommitHint(ctx, target) } return nil @@ -114,6 +119,46 @@ func printCheckpointRemoteHint(target string) { fmt.Fprintln(os.Stderr, "[entire] Checkpoints are saved locally but not synced. Ensure you have access to the checkpoint remote.") } +// settingsHintOnce ensures the settings commit hint prints at most once per process. +var settingsHintOnce sync.Once + +// printSettingsCommitHint prints a hint after a successful checkpoint remote push +// when the committed .entire/settings.json does not contain a checkpoint_remote config. +// entire.io discovers the external checkpoint repo by reading the committed project +// settings, so the checkpoint_remote must be present in HEAD:.entire/settings.json +// (not just in settings.local.json or uncommitted local changes). +// Uses sync.Once to avoid duplicates when multiple branches/refs are pushed in a +// single pre-push invocation. +func printSettingsCommitHint(ctx context.Context, target string) { + if !isURL(target) { + return + } + settingsHintOnce.Do(func() { + if isCheckpointRemoteCommitted(ctx) { + return + } + fmt.Fprintln(os.Stderr, "[entire] Note: Checkpoints were pushed to a separate checkpoint remote, but .entire/settings.json does not contain checkpoint_remote in the latest commit. entire.io will not be able to discover these checkpoints until checkpoint_remote is committed and pushed in .entire/settings.json.") + }) +} + +// isCheckpointRemoteCommitted returns true if the committed .entire/settings.json +// at HEAD contains a valid checkpoint_remote configuration. This is the true +// discoverability check: entire.io reads from committed project settings, not from +// local overrides or uncommitted changes. +func isCheckpointRemoteCommitted(ctx context.Context) bool { + cmd := exec.CommandContext(ctx, "git", "show", "HEAD:.entire/settings.json") + output, err := cmd.Output() + if err != nil { + return false // file doesn't exist at HEAD + } + // Parse the committed content and check for checkpoint_remote + committed, err := settings.LoadFromBytes(output) + if err != nil { + return false + } + return committed.GetCheckpointRemote() != nil +} + // tryPushSessionsCommon attempts to push the sessions branch. func tryPushSessionsCommon(ctx context.Context, remote, branchName string) error { ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index a5e246458..34a9d0c84 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -1,10 +1,12 @@ package strategy import ( + "bytes" "context" "os" "os/exec" "path/filepath" + "sync" "testing" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -823,3 +825,277 @@ func TestFetchAndRebase_URLTarget_ReconcilesFetchedTempRef(t *testing.T) { _, err = repo.Reference(plumbing.ReferenceName("refs/entire-fetch-tmp/"+branchName), true) assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "temporary fetched ref should be cleaned up") } + +// TestIsCheckpointRemoteCommitted verifies that the discoverability check reads +// the committed content of .entire/settings.json at HEAD, not just tracking status. +// Not parallel: uses t.Chdir(). +func TestIsCheckpointRemoteCommitted(t *testing.T) { + checkpointRemoteSettings := `{"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}}}` + + t.Run("false when settings.json not committed", 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") + + // Create .entire/settings.json with checkpoint_remote but don't commit it + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) + + t.Chdir(tmpDir) + assert.False(t, isCheckpointRemoteCommitted(context.Background())) + }) + + t.Run("false when committed settings.json has no checkpoint_remote", 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") + + // Commit settings.json without checkpoint_remote + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{}`), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "add settings") + + t.Chdir(tmpDir) + assert.False(t, isCheckpointRemoteCommitted(context.Background())) + }) + + t.Run("true when committed settings.json has checkpoint_remote", 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") + + // Commit settings.json with checkpoint_remote + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "add settings") + + t.Chdir(tmpDir) + assert.True(t, isCheckpointRemoteCommitted(context.Background())) + }) + + t.Run("false when checkpoint_remote only in local changes", 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") + + // Commit settings.json without checkpoint_remote + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{}`), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "add settings without remote") + + // Now add checkpoint_remote locally but don't commit + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) + + t.Chdir(tmpDir) + assert.False(t, isCheckpointRemoteCommitted(context.Background()), + "uncommitted checkpoint_remote should not count as discoverable") + }) + + t.Run("works from subdirectory", 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") + + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "add settings") + + subDir := filepath.Join(tmpDir, "subdir") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + t.Chdir(subDir) + assert.True(t, isCheckpointRemoteCommitted(context.Background()), + "should detect committed checkpoint_remote from subdirectory") + }) +} + +// TestPrintSettingsCommitHint verifies the hint only prints for URL targets +// when checkpoint_remote is not discoverable from committed settings, and only +// once per process via sync.Once. +// Not parallel: uses t.Chdir() and resets package-level settingsHintOnce. +func TestPrintSettingsCommitHint(t *testing.T) { + checkpointRemoteSettings := `{"strategy_options":{"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}}}` + + t.Run("no hint for non-URL target", func(t *testing.T) { + settingsHintOnce = sync.Once{} + + 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") + t.Chdir(tmpDir) + + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + printSettingsCommitHint(context.Background(), "origin") + + w.Close() + var buf bytes.Buffer + if _, readErr := buf.ReadFrom(r); readErr != nil { + t.Fatalf("read pipe: %v", readErr) + } + os.Stderr = old + + assert.Empty(t, buf.String(), "should not print hint for non-URL target") + }) + + t.Run("hint when checkpoint_remote not in committed settings", func(t *testing.T) { + settingsHintOnce = sync.Once{} + + 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") + + // Create .entire/settings.json but don't commit it + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) + t.Chdir(tmpDir) + + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git") + + w.Close() + var buf bytes.Buffer + if _, readErr := buf.ReadFrom(r); readErr != nil { + t.Fatalf("read pipe: %v", readErr) + } + os.Stderr = old + + assert.Contains(t, buf.String(), "does not contain checkpoint_remote") + assert.Contains(t, buf.String(), "entire.io will not be able to discover") + }) + + t.Run("hint when committed settings lacks checkpoint_remote", func(t *testing.T) { + settingsHintOnce = sync.Once{} + + 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") + + // Commit settings.json without checkpoint_remote + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{}`), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "add settings") + t.Chdir(tmpDir) + + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git") + + w.Close() + var buf bytes.Buffer + if _, readErr := buf.ReadFrom(r); readErr != nil { + t.Fatalf("read pipe: %v", readErr) + } + os.Stderr = old + + assert.Contains(t, buf.String(), "does not contain checkpoint_remote", + "should warn when committed settings.json exists but lacks checkpoint_remote") + }) + + t.Run("no hint when checkpoint_remote is committed", func(t *testing.T) { + settingsHintOnce = sync.Once{} + + 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") + + // Commit settings.json with checkpoint_remote + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) + testutil.GitAdd(t, tmpDir, ".entire/settings.json") + testutil.GitCommit(t, tmpDir, "add settings with checkpoint remote") + t.Chdir(tmpDir) + + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git") + + w.Close() + var buf bytes.Buffer + if _, readErr := buf.ReadFrom(r); readErr != nil { + t.Fatalf("read pipe: %v", readErr) + } + os.Stderr = old + + assert.Empty(t, buf.String(), "should not print hint when checkpoint_remote is committed") + }) + + t.Run("prints only once per process", func(t *testing.T) { + settingsHintOnce = sync.Once{} + + 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") + t.Chdir(tmpDir) + + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + + // Call twice — should only print once + printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git") + printSettingsCommitHint(context.Background(), "git@github.com:org/repo.git") + + w.Close() + var buf bytes.Buffer + if _, readErr := buf.ReadFrom(r); readErr != nil { + t.Fatalf("read pipe: %v", readErr) + } + os.Stderr = old + + count := bytes.Count(buf.Bytes(), []byte("does not contain checkpoint_remote")) + assert.Equal(t, 1, count, "hint should print exactly once, got %d", count) + }) +} diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index 3f5460b0b..a778077b6 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -73,6 +73,7 @@ func doPushRef(ctx context.Context, target string, refName plumbing.ReferenceNam if err := tryPushRef(ctx, target, refName); err == nil { stop(" done") + printSettingsCommitHint(ctx, target) return nil } stop("") @@ -97,6 +98,7 @@ func doPushRef(ctx context.Context, target string, refName plumbing.ReferenceNam printCheckpointRemoteHint(target) } else { stop(" done") + printSettingsCommitHint(ctx, target) } return nil