From 2748ecc7406ec37eda2920c22b76518b4c778ff2 Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 2 Apr 2026 21:38:36 +0100 Subject: [PATCH 1/2] fix: pass fetched ref to metadata reconciliation for checkpoint URL flow ReconcileDisconnectedMetadataBranch was hardcoded to check refs/remotes/origin/, but when pushing to a checkpoint URL (ENTIRE_CHECKPOINT_TOKEN flow), fetchAndMergeSessionsCommon fetches to refs/entire-fetch-tmp/. This meant reconciliation never found the remote ref and returned early, leaving a disconnected "Initialize sessions branch" orphan commit in the metadata branch history. Add a remoteRefName parameter to both IsMetadataDisconnected and ReconcileDisconnectedMetadataBranch so callers pass the actual reference to compare against. The push path now passes fetchedRefName (which is already computed as either origin or temp ref), enabling reconciliation to work correctly for both flows. Signed-off-by: Paulo Gomes Assisted-by: Claude Opus 4.6 Entire-Checkpoint: 4108db521b5d --- cmd/entire/cli/doctor.go | 6 +- cmd/entire/cli/strategy/metadata_reconcile.go | 16 ++-- .../cli/strategy/metadata_reconcile_test.go | 90 ++++++++++++++++--- cmd/entire/cli/strategy/push_common.go | 2 +- 4 files changed, 91 insertions(+), 23 deletions(-) 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..8bd539344 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -172,7 +172,7 @@ 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 { + if reconcileErr := ReconcileDisconnectedMetadataBranch(ctx, repo, fetchedRefName, os.Stderr); reconcileErr != nil { return fmt.Errorf("metadata reconciliation failed: %w", reconcileErr) } From ab4dc2ed8207b4513e1c0c14ab5ba0e722b450cb Mon Sep 17 00:00:00 2001 From: Paulo Gomes Date: Thu, 2 Apr 2026 21:52:21 +0100 Subject: [PATCH 2/2] fix: guard metadata reconciliation to only run for the metadata branch fetchAndMergeSessionsCommon is a generic branch sync function, but ReconcileDisconnectedMetadataBranch always operates on the metadata branch (paths.MetadataBranchName). Without a guard, reusing this function for a non-metadata branch would compare the metadata branch against an unrelated remote ref, risking incorrect cherry-picks. Add a branchName == paths.MetadataBranchName check so reconciliation only runs when syncing the metadata branch. Signed-off-by: Paulo Gomes Assisted-by: Claude Opus 4.6 Entire-Checkpoint: 311cbcd1ffe0 --- cmd/entire/cli/strategy/push_common.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 8bd539344..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, fetchedRefName, 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)