diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index 2c2a988e5..2d6a9be13 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/huh" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -319,7 +320,8 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error { } ctx := cmd.Context() - disconnected, err := strategy.IsMetadataDisconnected(ctx, repo) + originRef := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + disconnected, err := strategy.IsMetadataDisconnected(ctx, repo, originRef) if err != nil { return fmt.Errorf("could not check metadata branch state: %w", err) } @@ -357,7 +359,7 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error { } } - if fixErr := strategy.ReconcileDisconnectedMetadataBranch(ctx, repo, cmd.ErrOrStderr()); fixErr != nil { + if fixErr := strategy.ReconcileDisconnectedMetadataBranch(ctx, repo, originRef, cmd.ErrOrStderr()); fixErr != nil { return fmt.Errorf("failed to reconcile metadata branches: %w", fixErr) } diff --git a/cmd/entire/cli/strategy/metadata_reconcile.go b/cmd/entire/cli/strategy/metadata_reconcile.go index 52b0f9fc4..1c95515ab 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile.go +++ b/cmd/entire/cli/strategy/metadata_reconcile.go @@ -28,7 +28,10 @@ var disconnectedOnce sync.Once //nolint:gochecknoglobals // intentional per-proc // Returns (false, nil) if either branch is missing, they point to the same hash, // or they share a common ancestor (normal divergence handled by push merge). // Returns (true, nil) only when both exist and are truly disconnected. -func IsMetadataDisconnected(ctx context.Context, repo *git.Repository) (bool, error) { +// +// remoteRefName is the reference to compare against (e.g., refs/remotes/origin/ +// or refs/entire-fetch-tmp/ when fetching from a checkpoint URL). +func IsMetadataDisconnected(ctx context.Context, repo *git.Repository, remoteRefName plumbing.ReferenceName) (bool, error) { refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) localRef, err := repo.Reference(refName, true) if errors.Is(err, plumbing.ErrReferenceNotFound) { @@ -38,7 +41,6 @@ func IsMetadataDisconnected(ctx context.Context, repo *git.Repository) (bool, er return false, fmt.Errorf("failed to check local metadata branch: %w", err) } - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) remoteRef, err := repo.Reference(remoteRefName, true) if errors.Is(err, plumbing.ErrReferenceNotFound) { return false, nil @@ -75,7 +77,7 @@ func WarnIfMetadataDisconnected() { slog.String("error", err.Error())) return } - disconnected, err := IsMetadataDisconnected(ctx, repo) + disconnected, err := IsMetadataDisconnected(ctx, repo, plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)) if err != nil { logging.Debug(ctx, "metadata disconnection check failed", slog.String("error", err.Error())) @@ -94,13 +96,16 @@ func WarnIfMetadataDisconnected() { // only happens due to the empty-orphan bug. Diverged (shared ancestor) is normal // and handled by the push path's tree merge. // +// remoteRefName is the reference to compare against (e.g., refs/remotes/origin/ +// or refs/entire-fetch-tmp/ when fetching from a checkpoint URL). +// // Repair strategy: cherry-pick local commits onto remote tip, preserving all data. // Checkpoint shards use unique paths (//), so cherry-picks always // apply cleanly. // // Progress messages are written to w (typically os.Stderr for hooks or // cmd.ErrOrStderr() for commands). -func ReconcileDisconnectedMetadataBranch(ctx context.Context, repo *git.Repository, w io.Writer) error { +func ReconcileDisconnectedMetadataBranch(ctx context.Context, repo *git.Repository, remoteRefName plumbing.ReferenceName, w io.Writer) error { refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) // Check local branch @@ -112,8 +117,7 @@ func ReconcileDisconnectedMetadataBranch(ctx context.Context, repo *git.Reposito return fmt.Errorf("failed to check local metadata branch: %w", err) } - // Check remote-tracking branch - remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + // Check remote/fetched branch remoteRef, err := repo.Reference(remoteRefName, true) if errors.Is(err, plumbing.ErrReferenceNotFound) { return nil // No remote branch — nothing to reconcile diff --git a/cmd/entire/cli/strategy/metadata_reconcile_test.go b/cmd/entire/cli/strategy/metadata_reconcile_test.go index 0f08d1057..c2b3bad6d 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile_test.go +++ b/cmd/entire/cli/strategy/metadata_reconcile_test.go @@ -58,7 +58,7 @@ func TestReconcileDisconnected_NoRemote(t *testing.T) { } // Should be a no-op (no remote) - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -76,7 +76,7 @@ func TestReconcileDisconnected_NoLocal(t *testing.T) { } // No local branch → no-op - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -98,7 +98,7 @@ func TestReconcileDisconnected_SameHash(t *testing.T) { } // Same hash → no-op - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -139,7 +139,7 @@ func TestReconcileDisconnected_SharedAncestry(t *testing.T) { } // Shared ancestry → no-op - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("unexpected error: %v", err) } } @@ -186,7 +186,7 @@ func TestReconcileDisconnected_Disconnected(t *testing.T) { } // Run reconciliation - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err) } @@ -297,7 +297,7 @@ func TestReconcileDisconnected_MultipleLocalCheckpoints(t *testing.T) { } // Run reconciliation - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err) } @@ -416,7 +416,7 @@ func TestIsMetadataDisconnected_NoRemote(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -437,7 +437,7 @@ func TestIsMetadataDisconnected_NoLocal(t *testing.T) { } // No local branch → false - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -461,7 +461,7 @@ func TestIsMetadataDisconnected_SameHash(t *testing.T) { t.Fatalf("EnsureMetadataBranch failed: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -503,7 +503,7 @@ func TestIsMetadataDisconnected_SharedAncestry(t *testing.T) { t.Fatalf("failed to re-open repo: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -538,7 +538,7 @@ func TestIsMetadataDisconnected_Disconnected(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - disconnected, err := IsMetadataDisconnected(context.Background(), repo) + disconnected, err := IsMetadataDisconnected(context.Background(), repo, originMetadataRef()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -593,7 +593,7 @@ func TestReconcileDisconnected_ModifiedEntries(t *testing.T) { t.Fatalf("failed to open repo: %v", err) } - if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard); err != nil { + if err := ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard); err != nil { t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err) } @@ -693,7 +693,7 @@ func TestReconcileDisconnected_AllEmptyOrphans(t *testing.T) { remoteRef, err := repo.Reference(remoteRefName, true) require.NoError(t, err) - err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard) + err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard) require.NoError(t, err) // Local branch should now point to the remote tip (reset, not cherry-picked) @@ -743,7 +743,7 @@ func TestReconcileDisconnected_CherryPickDeletion(t *testing.T) { repo, err := git.PlainOpen(cloneDir) require.NoError(t, err) - err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, io.Discard) + err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, originMetadataRef(), io.Discard) require.NoError(t, err) // Verify merged tree: should have remote data + first local checkpoint, @@ -766,3 +766,65 @@ func TestReconcileDisconnected_CherryPickDeletion(t *testing.T) { // Second local checkpoint should be deleted assert.NotContains(t, entries, "cd/ef01234567/metadata.json", "deleted checkpoint should not be present") } + +// TestReconcileDisconnected_TempRef verifies that reconciliation works when the +// remote data is stored under a temp ref (refs/entire-fetch-tmp/) instead of the +// standard remote-tracking ref (refs/remotes/origin/). This is the code path used +// when pushing to a checkpoint URL with ENTIRE_CHECKPOINT_TOKEN. +func TestReconcileDisconnected_TempRef(t *testing.T) { + t.Parallel() + + bareDir := initBareWithMetadataBranch(t) + cloneDir, run := cloneWithConfig(t, bareDir) + + // Create a disconnected local metadata branch (simulating the empty-orphan bug) + run("checkout", "--orphan", "temp-orphan") + run("rm", "-rf", ".") + localDir := filepath.Join(cloneDir, "ab", "cdef012345") + require.NoError(t, os.MkdirAll(localDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(localDir, "metadata.json"), []byte(`{"checkpoint_id":"abcdef012345"}`), 0o644)) + run("add", ".") + run("commit", "-m", "Checkpoint: abcdef012345") + run("branch", "-f", paths.MetadataBranchName, "temp-orphan") + run("checkout", "main") + + repo, err := git.PlainOpen(cloneDir) + require.NoError(t, err) + + // Copy the remote-tracking ref to a temp ref (simulating what fetchAndMergeSessionsCommon + // does for URL targets). The temp ref holds the same commit as origin's remote-tracking ref. + remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) + remoteRef, err := repo.Reference(remoteRefName, true) + require.NoError(t, err) + + tmpRefName := plumbing.ReferenceName("refs/entire-fetch-tmp/" + paths.MetadataBranchName) + tmpRef := plumbing.NewHashReference(tmpRefName, remoteRef.Hash()) + require.NoError(t, repo.Storer.SetReference(tmpRef)) + + // Reconcile against the temp ref — this should fix the disconnection + err = ReconcileDisconnectedMetadataBranch(context.Background(), repo, tmpRefName, io.Discard) + require.NoError(t, err) + + // Verify result: local branch should now be parented off the remote (temp ref) tip + newRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err) + tipCommit, err := repo.CommitObject(newRef.Hash()) + require.NoError(t, err) + + require.Len(t, tipCommit.ParentHashes, 1, "expected linear history") + assert.Equal(t, remoteRef.Hash(), tipCommit.ParentHashes[0], + "cherry-picked commit should be parented off temp ref (remote) tip") + + // Verify merged tree contains both local and remote data + tree, err := tipCommit.Tree() + require.NoError(t, err) + entries := make(map[string]object.TreeEntry) + require.NoError(t, checkpoint.FlattenTree(repo, tree, "", entries)) + assert.Contains(t, entries, "metadata.json", "remote data should be preserved") + assert.Contains(t, entries, "ab/cdef012345/metadata.json", "local data should be preserved") +} + +// originMetadataRef returns the standard remote-tracking ref for the metadata branch. +func originMetadataRef() plumbing.ReferenceName { + return plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName) +} diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 2a595756f..cb480ca34 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -10,6 +10,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" @@ -172,8 +173,12 @@ func fetchAndMergeSessionsCommon(ctx context.Context, target, branchName string) // this cherry-picks local commits onto remote tip, updating the local ref. // If reconciliation fails, abort — proceeding to tree merge on disconnected // branches would silently combine unrelated histories. - if reconcileErr := ReconcileDisconnectedMetadataBranch(ctx, repo, os.Stderr); reconcileErr != nil { - return fmt.Errorf("metadata reconciliation failed: %w", reconcileErr) + // Only run for the metadata branch — reconciliation is specific to that branch's + // orphan-detection logic and would be incorrect for other branches. + if branchName == paths.MetadataBranchName { + if reconcileErr := ReconcileDisconnectedMetadataBranch(ctx, repo, fetchedRefName, os.Stderr); reconcileErr != nil { + return fmt.Errorf("metadata reconciliation failed: %w", reconcileErr) + } } // Get local branch (re-read after potential reconciliation update)