Skip to content
Open
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
12 changes: 12 additions & 0 deletions cmd/entire/cli/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 45 additions & 0 deletions cmd/entire/cli/strategy/push_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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("")
Expand All @@ -99,6 +103,7 @@ func doPushBranch(ctx context.Context, target, branchName string) error {
printCheckpointRemoteHint(target)
} else {
stop(" done")
printSettingsCommitHint(ctx, target)
}

return nil
Expand All @@ -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)
Expand Down
276 changes: 276 additions & 0 deletions cmd/entire/cli/strategy/push_common_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package strategy

import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
Expand Down Expand Up @@ -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)
})
}
Loading
Loading