From b5c406d773db2acf3768c2cff7de38d8c8252b30 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 7 Apr 2026 14:14:14 -0700 Subject: [PATCH 1/4] fix: hint to commit settings.json when using checkpoint remote When pushing checkpoints to an external checkpoint remote, entire.io needs .entire/settings.json committed to discover where checkpoint data lives. Show a note after successful pushes when settings.json is not tracked by git. Refs: #859 Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: a887063691eb --- cmd/entire/cli/strategy/push_common.go | 25 +++++++++++++++++++++++++ cmd/entire/cli/strategy/push_v2.go | 2 ++ 2 files changed, 27 insertions(+) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index c304ad09e..696145213 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -73,6 +73,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 +100,7 @@ func doPushBranch(ctx context.Context, target, branchName string) error { printCheckpointRemoteHint(target) } else { stop(" done") + printSettingsCommitHint(ctx, target) } return nil @@ -114,6 +116,29 @@ func printCheckpointRemoteHint(target string) { fmt.Fprintln(os.Stderr, "[entire] Checkpoints are saved locally but not synced. Ensure you have access to the checkpoint remote.") } +// printSettingsCommitHint prints a one-time hint after a successful checkpoint remote push +// when .entire/settings.json is not tracked by git. entire.io needs the committed settings +// to discover the external checkpoint repo. +func printSettingsCommitHint(ctx context.Context, target string) { + if !isURL(target) { + return + } + if isSettingsTrackedByGit(ctx) { + return + } + fmt.Fprintln(os.Stderr, "[entire] Note: Commit and push .entire/settings.json for entire.io to discover your remote checkpoint.") +} + +// isSettingsTrackedByGit returns true if .entire/settings.json is tracked by git. +func isSettingsTrackedByGit(ctx context.Context) bool { + cmd := exec.CommandContext(ctx, "git", "ls-files", ".entire/settings.json") + output, err := cmd.Output() + if err != nil { + return false + } + return len(strings.TrimSpace(string(output))) > 0 +} + // 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_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 From e1ba72ca998f0435df4f9f54035384adfcecb654 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Tue, 7 Apr 2026 14:48:40 -0700 Subject: [PATCH 2/4] fix: deduplicate hint and fix CWD-relative git ls-files Address review feedback: - Use sync.Once to print the settings hint at most once per push - Use repo-root-relative pathspec (:/) for git ls-files to work correctly from subdirectories - Add tests for isSettingsTrackedByGit and printSettingsCommitHint Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 06fbfcec1fa0 --- cmd/entire/cli/strategy/push_common.go | 22 ++- cmd/entire/cli/strategy/push_common_test.go | 195 ++++++++++++++++++++ 2 files changed, 210 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 696145213..2ba6c6c65 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "strings" + "sync" "time" "github.com/go-git/go-git/v6" @@ -116,22 +117,29 @@ func printCheckpointRemoteHint(target string) { fmt.Fprintln(os.Stderr, "[entire] Checkpoints are saved locally but not synced. Ensure you have access to the checkpoint remote.") } -// printSettingsCommitHint prints a one-time hint after a successful checkpoint remote push +// 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 .entire/settings.json is not tracked by git. entire.io needs the committed settings -// to discover the external checkpoint repo. +// to discover the external checkpoint repo. 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 } - if isSettingsTrackedByGit(ctx) { - return - } - fmt.Fprintln(os.Stderr, "[entire] Note: Commit and push .entire/settings.json for entire.io to discover your remote checkpoint.") + settingsHintOnce.Do(func() { + if isSettingsTrackedByGit(ctx) { + return + } + fmt.Fprintln(os.Stderr, "[entire] Note: Commit and push .entire/settings.json for entire.io to discover your remote checkpoint.") + }) } // isSettingsTrackedByGit returns true if .entire/settings.json is tracked by git. +// Uses repo-root-relative pathspec (:/) to work correctly from any subdirectory. func isSettingsTrackedByGit(ctx context.Context) bool { - cmd := exec.CommandContext(ctx, "git", "ls-files", ".entire/settings.json") + cmd := exec.CommandContext(ctx, "git", "ls-files", ":/.entire/settings.json") output, err := cmd.Output() if err != nil { return false diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index a5e246458..d962f60be 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,196 @@ 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") } + +// TestIsSettingsTrackedByGit verifies detection of .entire/settings.json tracking status. +// Not parallel: uses t.Chdir(). +func TestIsSettingsTrackedByGit(t *testing.T) { + t.Run("untracked", 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 but don't track it + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{}`), 0o644)) + + t.Chdir(tmpDir) + assert.False(t, isSettingsTrackedByGit(context.Background())) + }) + + t.Run("tracked", 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 and track .entire/settings.json + 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.True(t, isSettingsTrackedByGit(context.Background())) + }) + + 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") + + // Create and track .entire/settings.json + 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") + + // Run from a subdirectory + subDir := filepath.Join(tmpDir, "subdir") + require.NoError(t, os.MkdirAll(subDir, 0o755)) + t.Chdir(subDir) + assert.True(t, isSettingsTrackedByGit(context.Background()), "should detect tracked file from subdirectory") + }) +} + +// TestPrintSettingsCommitHint verifies the hint only prints for URL targets +// with untracked settings, and only once per process via sync.Once. +// Not parallel: uses t.Chdir() and resets package-level settingsHintOnce. +func TestPrintSettingsCommitHint(t *testing.T) { + t.Run("no hint for non-URL target", func(t *testing.T) { + // Reset the sync.Once for this test + 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) + + // Capture stderr + 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 for URL target with untracked 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 track it + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{}`), 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(), "Commit and push .entire/settings.json") + }) + + t.Run("no hint when settings is tracked", 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 and track .entire/settings.json + 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.Empty(t, buf.String(), "should not print hint when settings.json is tracked") + }) + + 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") + + entireDir := filepath.Join(tmpDir, ".entire") + require.NoError(t, os.MkdirAll(entireDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{}`), 0o644)) + 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("Commit and push")) + assert.Equal(t, 1, count, "hint should print exactly once, got %d", count) + }) +} From eb29be8344812d09e23203d0279ab0d67cbc992e Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 22:53:29 -0700 Subject: [PATCH 3/4] fix: clarify external checkpoint discovery warning Entire-Checkpoint: d2972e70b180 --- cmd/entire/cli/strategy/push_common.go | 2 +- cmd/entire/cli/strategy/push_common_test.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 2ba6c6c65..3544fd7ae 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -132,7 +132,7 @@ func printSettingsCommitHint(ctx context.Context, target string) { if isSettingsTrackedByGit(ctx) { return } - fmt.Fprintln(os.Stderr, "[entire] Note: Commit and push .entire/settings.json for entire.io to discover your remote checkpoint.") + fmt.Fprintln(os.Stderr, "[entire] Note: Checkpoints were pushed to a separate checkpoint remote, but .entire/settings.json is not tracked. entire.io may not be able to find this checkpoint until that file is committed and pushed.") }) } diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index d962f60be..0382ccf3e 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -947,7 +947,8 @@ func TestPrintSettingsCommitHint(t *testing.T) { } os.Stderr = old - assert.Contains(t, buf.String(), "Commit and push .entire/settings.json") + assert.Contains(t, buf.String(), ".entire/settings.json is not tracked") + assert.Contains(t, buf.String(), "entire.io may not be able to find this checkpoint") }) t.Run("no hint when settings is tracked", func(t *testing.T) { @@ -1014,7 +1015,7 @@ func TestPrintSettingsCommitHint(t *testing.T) { } os.Stderr = old - count := bytes.Count(buf.Bytes(), []byte("Commit and push")) + count := bytes.Count(buf.Bytes(), []byte(".entire/settings.json is not tracked")) assert.Equal(t, 1, count, "hint should print exactly once, got %d", count) }) } From ca9f7ea4bec08ad49c6917270c3557c6353f67b7 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 9 Apr 2026 21:36:57 -0700 Subject: [PATCH 4/4] fix: check committed checkpoint_remote for discoverability warning Replace git ls-files tracking check with true discoverability check that reads HEAD:.entire/settings.json and verifies checkpoint_remote is present in committed content. Warns correctly when config only exists in settings.local.json or uncommitted local changes. Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: a4404d2b963b --- cmd/entire/cli/settings/settings.go | 12 ++ cmd/entire/cli/strategy/push_common.go | 32 +++-- cmd/entire/cli/strategy/push_common_test.go | 134 ++++++++++++++++---- 3 files changed, 141 insertions(+), 37 deletions(-) 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 3544fd7ae..1f464d2cc 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -11,6 +11,8 @@ import ( "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" @@ -121,30 +123,40 @@ func printCheckpointRemoteHint(target string) { var settingsHintOnce sync.Once // printSettingsCommitHint prints a hint after a successful checkpoint remote push -// when .entire/settings.json is not tracked by git. entire.io needs the committed settings -// to discover the external checkpoint repo. Uses sync.Once to avoid duplicates when -// multiple branches/refs are pushed in a single pre-push invocation. +// 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 isSettingsTrackedByGit(ctx) { + if isCheckpointRemoteCommitted(ctx) { return } - fmt.Fprintln(os.Stderr, "[entire] Note: Checkpoints were pushed to a separate checkpoint remote, but .entire/settings.json is not tracked. entire.io may not be able to find this checkpoint until that file is committed and pushed.") + 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.") }) } -// isSettingsTrackedByGit returns true if .entire/settings.json is tracked by git. -// Uses repo-root-relative pathspec (:/) to work correctly from any subdirectory. -func isSettingsTrackedByGit(ctx context.Context) bool { - cmd := exec.CommandContext(ctx, "git", "ls-files", ":/.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 len(strings.TrimSpace(string(output))) > 0 + return committed.GetCheckpointRemote() != nil } // tryPushSessionsCommon attempts to push the sessions branch. diff --git a/cmd/entire/cli/strategy/push_common_test.go b/cmd/entire/cli/strategy/push_common_test.go index 0382ccf3e..34a9d0c84 100644 --- a/cmd/entire/cli/strategy/push_common_test.go +++ b/cmd/entire/cli/strategy/push_common_test.go @@ -826,33 +826,37 @@ func TestFetchAndRebase_URLTarget_ReconcilesFetchedTempRef(t *testing.T) { assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "temporary fetched ref should be cleaned up") } -// TestIsSettingsTrackedByGit verifies detection of .entire/settings.json tracking status. +// 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 TestIsSettingsTrackedByGit(t *testing.T) { - t.Run("untracked", func(t *testing.T) { +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 but don't track it + // 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(`{}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) t.Chdir(tmpDir) - assert.False(t, isSettingsTrackedByGit(context.Background())) + assert.False(t, isCheckpointRemoteCommitted(context.Background())) }) - t.Run("tracked", func(t *testing.T) { + 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") - // Create and track .entire/settings.json + // 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)) @@ -860,37 +864,81 @@ func TestIsSettingsTrackedByGit(t *testing.T) { testutil.GitCommit(t, tmpDir, "add settings") t.Chdir(tmpDir) - assert.True(t, isSettingsTrackedByGit(context.Background())) + assert.False(t, isCheckpointRemoteCommitted(context.Background())) }) - t.Run("works from subdirectory", func(t *testing.T) { + 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") - // Create and track .entire/settings.json + // 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") - // Run from a subdirectory subDir := filepath.Join(tmpDir, "subdir") require.NoError(t, os.MkdirAll(subDir, 0o755)) t.Chdir(subDir) - assert.True(t, isSettingsTrackedByGit(context.Background()), "should detect tracked file from subdirectory") + assert.True(t, isCheckpointRemoteCommitted(context.Background()), + "should detect committed checkpoint_remote from subdirectory") }) } // TestPrintSettingsCommitHint verifies the hint only prints for URL targets -// with untracked settings, and only once per process via sync.Once. +// 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) { - // Reset the sync.Once for this test settingsHintOnce = sync.Once{} tmpDir := t.TempDir() @@ -900,7 +948,6 @@ func TestPrintSettingsCommitHint(t *testing.T) { testutil.GitCommit(t, tmpDir, "init") t.Chdir(tmpDir) - // Capture stderr old := os.Stderr r, w, err := os.Pipe() require.NoError(t, err) @@ -918,7 +965,7 @@ func TestPrintSettingsCommitHint(t *testing.T) { assert.Empty(t, buf.String(), "should not print hint for non-URL target") }) - t.Run("hint for URL target with untracked settings", func(t *testing.T) { + t.Run("hint when checkpoint_remote not in committed settings", func(t *testing.T) { settingsHintOnce = sync.Once{} tmpDir := t.TempDir() @@ -927,10 +974,11 @@ func TestPrintSettingsCommitHint(t *testing.T) { testutil.GitAdd(t, tmpDir, "f.txt") testutil.GitCommit(t, tmpDir, "init") - // Create .entire/settings.json but don't track it + // 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(`{}`), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(entireDir, "settings.json"), + []byte(checkpointRemoteSettings), 0o644)) t.Chdir(tmpDir) old := os.Stderr @@ -947,11 +995,11 @@ func TestPrintSettingsCommitHint(t *testing.T) { } os.Stderr = old - assert.Contains(t, buf.String(), ".entire/settings.json is not tracked") - assert.Contains(t, buf.String(), "entire.io may not be able to find this checkpoint") + 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("no hint when settings is tracked", func(t *testing.T) { + t.Run("hint when committed settings lacks checkpoint_remote", func(t *testing.T) { settingsHintOnce = sync.Once{} tmpDir := t.TempDir() @@ -960,7 +1008,7 @@ func TestPrintSettingsCommitHint(t *testing.T) { testutil.GitAdd(t, tmpDir, "f.txt") testutil.GitCommit(t, tmpDir, "init") - // Create and track .entire/settings.json + // 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)) @@ -982,10 +1030,11 @@ func TestPrintSettingsCommitHint(t *testing.T) { } os.Stderr = old - assert.Empty(t, buf.String(), "should not print hint when settings.json is tracked") + assert.Contains(t, buf.String(), "does not contain checkpoint_remote", + "should warn when committed settings.json exists but lacks checkpoint_remote") }) - t.Run("prints only once per process", func(t *testing.T) { + t.Run("no hint when checkpoint_remote is committed", func(t *testing.T) { settingsHintOnce = sync.Once{} tmpDir := t.TempDir() @@ -994,9 +1043,40 @@ func TestPrintSettingsCommitHint(t *testing.T) { 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(`{}`), 0o644)) + 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 @@ -1015,7 +1095,7 @@ func TestPrintSettingsCommitHint(t *testing.T) { } os.Stderr = old - count := bytes.Count(buf.Bytes(), []byte(".entire/settings.json is not tracked")) + count := bytes.Count(buf.Bytes(), []byte("does not contain checkpoint_remote")) assert.Equal(t, 1, count, "hint should print exactly once, got %d", count) }) }