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
6 changes: 4 additions & 2 deletions cmd/entire/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}

Expand Down
16 changes: 10 additions & 6 deletions cmd/entire/cli/strategy/metadata_reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch>
// or refs/entire-fetch-tmp/<branch> 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) {
Expand All @@ -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
Expand Down Expand Up @@ -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()))
Expand All @@ -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/<branch>
// or refs/entire-fetch-tmp/<branch> when fetching from a checkpoint URL).
//
// Repair strategy: cherry-pick local commits onto remote tip, preserving all data.
// Checkpoint shards use unique paths (<id[:2]>/<id[2:]>/), 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
Expand All @@ -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
Expand Down
90 changes: 76 additions & 14 deletions cmd/entire/cli/strategy/metadata_reconcile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}
}
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
}
9 changes: 7 additions & 2 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/paths"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
Expand Down Expand Up @@ -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)
Expand Down
Loading