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
17 changes: 17 additions & 0 deletions cmd/entire/cli/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,23 @@ func (s *EntireSettings) IsPushSessionsDisabled() bool {
return false
}

// GetCheckpointRemote returns the configured checkpoint remote, if any.
// When set, checkpoint branches are pushed to this remote instead of the
// user's push remote. Returns empty string if not configured.
func (s *EntireSettings) GetCheckpointRemote() string {
if s.StrategyOptions == nil {
return ""
}
val, exists := s.StrategyOptions["checkpoint_remote"]
if !exists {
return ""
}
if strVal, ok := val.(string); ok && strVal != "" {
return strVal
}
return ""
}

// IsExternalAgentsEnabled checks if external agent discovery is enabled in settings.
// Returns false by default if settings cannot be loaded or the key is missing.
func IsExternalAgentsEnabled(ctx context.Context) bool {
Expand Down
108 changes: 108 additions & 0 deletions cmd/entire/cli/settings/settings_checkpoint_remote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package settings

import (
"os"
"testing"
)

func TestGetCheckpointRemote_NotConfigured(t *testing.T) {
t.Parallel()

s := &EntireSettings{}
if got := s.GetCheckpointRemote(); got != "" {
t.Errorf("GetCheckpointRemote() = %q, want empty string", got)
}
}

func TestGetCheckpointRemote_EmptyStrategyOptions(t *testing.T) {
t.Parallel()

s := &EntireSettings{
StrategyOptions: map[string]any{},
}
if got := s.GetCheckpointRemote(); got != "" {
t.Errorf("GetCheckpointRemote() = %q, want empty string", got)
}
}

func TestGetCheckpointRemote_Configured(t *testing.T) {
t.Parallel()

s := &EntireSettings{
StrategyOptions: map[string]any{
"checkpoint_remote": "private",
},
}
if got := s.GetCheckpointRemote(); got != "private" {
t.Errorf("GetCheckpointRemote() = %q, want %q", got, "private")
}
}

func TestGetCheckpointRemote_EmptyString(t *testing.T) {
t.Parallel()

s := &EntireSettings{
StrategyOptions: map[string]any{
"checkpoint_remote": "",
},
}
if got := s.GetCheckpointRemote(); got != "" {
t.Errorf("GetCheckpointRemote() = %q, want empty string", got)
}
}

func TestGetCheckpointRemote_WrongType(t *testing.T) {
t.Parallel()

s := &EntireSettings{
StrategyOptions: map[string]any{
"checkpoint_remote": 42,
},
}
if got := s.GetCheckpointRemote(); got != "" {
t.Errorf("GetCheckpointRemote() = %q, want empty string for non-string type", got)
}
}

func TestGetCheckpointRemote_JSONRoundTrip(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
settingsFile := tmpDir + "/settings.json"

content := `{
"enabled": true,
"strategy_options": {
"checkpoint_remote": "private-remote"
}
}`
if err := os.WriteFile(settingsFile, []byte(content), 0o644); err != nil {
t.Fatalf("Failed to write settings: %v", err)
}

s, err := LoadFromFile(settingsFile)
if err != nil {
t.Fatalf("LoadFromFile() error = %v", err)
}

if got := s.GetCheckpointRemote(); got != "private-remote" {
t.Errorf("GetCheckpointRemote() after JSON load = %q, want %q", got, "private-remote")
}
}

func TestGetCheckpointRemote_CoexistsWithPushSessions(t *testing.T) {
t.Parallel()

s := &EntireSettings{
StrategyOptions: map[string]any{
"push_sessions": false,
"checkpoint_remote": "private",
},
}
if got := s.GetCheckpointRemote(); got != "private" {
t.Errorf("GetCheckpointRemote() = %q, want %q", got, "private")
}
if !s.IsPushSessionsDisabled() {
t.Error("IsPushSessionsDisabled() = false, want true")
}
}
18 changes: 18 additions & 0 deletions cmd/entire/cli/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type EnableOptions struct {
UseProjectSettings bool
ForceHooks bool
SkipPushSessions bool
CheckpointRemote string
Telemetry bool
AbsoluteGitHookPath bool
}
Expand Down Expand Up @@ -111,6 +112,7 @@ modifying your active branch.`,
cmd.Flags().StringVar(&agentName, "agent", "", "Agent to set up hooks for (e.g., "+strings.Join(agent.StringList(), ", ")+"). Enables non-interactive mode.")
cmd.Flags().BoolVarP(&opts.ForceHooks, "force", "f", false, "Force reinstall hooks (removes existing Entire hooks first)")
cmd.Flags().BoolVar(&opts.SkipPushSessions, "skip-push-sessions", false, "Disable automatic pushing of session logs on git push")
cmd.Flags().StringVar(&opts.CheckpointRemote, "checkpoint-remote", "", "Push checkpoint branches to this remote instead of the default push remote")
cmd.Flags().BoolVar(&opts.Telemetry, "telemetry", true, "Enable anonymous usage analytics")
cmd.Flags().BoolVar(&opts.AbsoluteGitHookPath, "absolute-git-hook-path", false, "Embed full binary path in git hooks (for GUI git clients that don't source shell profiles)")

Expand Down Expand Up @@ -209,6 +211,14 @@ func runEnableInteractive(ctx context.Context, w io.Writer, agents []agent.Agent
settings.StrategyOptions["push_sessions"] = false
}

// Set checkpoint_remote option if --checkpoint-remote flag was provided
if opts.CheckpointRemote != "" {
if settings.StrategyOptions == nil {
settings.StrategyOptions = make(map[string]interface{})
}
settings.StrategyOptions["checkpoint_remote"] = opts.CheckpointRemote
}

// Determine which settings file to write to
// First run always creates settings.json (no prompt)
entireDirAbs, err := paths.AbsPath(ctx, paths.EntireDir)
Expand Down Expand Up @@ -629,6 +639,14 @@ func setupAgentHooksNonInteractive(ctx context.Context, w io.Writer, ag agent.Ag
settings.StrategyOptions["push_sessions"] = false
}

// Set checkpoint_remote option if --checkpoint-remote flag was provided
if opts.CheckpointRemote != "" {
if settings.StrategyOptions == nil {
settings.StrategyOptions = make(map[string]interface{})
}
settings.StrategyOptions["checkpoint_remote"] = opts.CheckpointRemote
}

// Handle telemetry for non-interactive mode
// Note: if telemetry is nil (not configured), it defaults to disabled
if !opts.Telemetry || os.Getenv("ENTIRE_TELEMETRY_OPTOUT") != "" {
Expand Down
6 changes: 4 additions & 2 deletions cmd/entire/cli/strategy/manual_commit_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ import (
// - "prompt" (default): ask user with option to enable auto
// - "false"/"off"/"no": never push
func (s *ManualCommitStrategy) PrePush(ctx context.Context, remote string) error {
checkpointRemote := resolveCheckpointRemote(ctx, remote)

_, pushCheckpointsSpan := perf.Start(ctx, "push_checkpoints_branch")
if err := pushSessionsBranchCommon(ctx, remote, paths.MetadataBranchName); err != nil {
if err := pushSessionsBranchCommon(ctx, checkpointRemote, paths.MetadataBranchName); err != nil {
pushCheckpointsSpan.RecordError(err)
pushCheckpointsSpan.End()
return err
}
pushCheckpointsSpan.End()

_, pushTrailsSpan := perf.Start(ctx, "push_trails_branch")
err := PushTrailsBranch(ctx, remote)
err := PushTrailsBranch(ctx, checkpointRemote)
pushTrailsSpan.RecordError(err)
pushTrailsSpan.End()
return err
Expand Down
51 changes: 51 additions & 0 deletions cmd/entire/cli/strategy/push_checkpoint_remote_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package strategy

import (
"testing"

"github.com/entireio/cli/cmd/entire/cli/testutil"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/config"
)

func TestRemoteExists_True(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
testutil.InitRepo(t, tmpDir)

repo, err := git.PlainOpen(tmpDir)
if err != nil {
t.Fatalf("failed to open repo: %v", err)
}

// Add a remote
_, err = repo.CreateRemote(&config.RemoteConfig{
Name: "private",
URLs: []string{"https://example.com/repo.git"},
})
if err != nil {
t.Fatalf("failed to create remote: %v", err)
}

if !remoteExists(repo, "private") {
t.Error("remoteExists() = false, want true for configured remote")
}
}

func TestRemoteExists_False(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
testutil.InitRepo(t, tmpDir)

repo, err := git.PlainOpen(tmpDir)
if err != nil {
t.Fatalf("failed to open repo: %v", err)
}

if remoteExists(repo, "nonexistent") {
t.Error("remoteExists() = true, want false for unconfigured remote")
}
}
35 changes: 35 additions & 0 deletions cmd/entire/cli/strategy/push_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/settings"

Expand Down Expand Up @@ -83,6 +84,40 @@ func isPushSessionsDisabled(ctx context.Context) bool {
return s.IsPushSessionsDisabled()
}

// resolveCheckpointRemote returns the remote to use for pushing checkpoint branches.
// If checkpoint_remote is configured in settings and the remote exists in git config,
// it is returned. Otherwise falls back to the provided default remote.
func resolveCheckpointRemote(ctx context.Context, defaultRemote string) string {
s, err := settings.Load(ctx)
if err != nil {
return defaultRemote
}
configured := s.GetCheckpointRemote()
if configured == "" {
return defaultRemote
}

repo, err := OpenRepository(ctx)
if err != nil {
return defaultRemote
}
if !remoteExists(repo, configured) {
logging.Warn(logging.WithComponent(ctx, "push"),
"configured checkpoint_remote does not exist, falling back to default",
"checkpoint_remote", configured,
"fallback_remote", defaultRemote,
)
return defaultRemote
}
return configured
}

// remoteExists checks if a named remote is configured in the git repository.
func remoteExists(repo *git.Repository, name string) bool {
_, err := repo.Remote(name)
return err == nil
}

// doPushBranch pushes the given branch to the remote with fetch+merge recovery.
func doPushBranch(ctx context.Context, remote, branchName string) error {
fmt.Fprintf(os.Stderr, "[entire] Pushing %s to %s...\n", branchName, remote)
Expand Down