Skip to content
Merged
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 @@ -314,7 +315,8 @@ func checkDisconnectedMetadata(cmd *cobra.Command, force bool) error {
}

ctx := cmd.Context()
disconnected, err := strategy.IsMetadataDisconnected(ctx, repo)
remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
disconnected, err := strategy.IsMetadataDisconnected(ctx, repo, remoteRefName)
if err != nil {
return fmt.Errorf("could not check metadata branch state: %w", err)
}
Expand Down Expand Up @@ -352,7 +354,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, remoteRefName, cmd.ErrOrStderr()); fixErr != nil {
return fmt.Errorf("failed to reconcile metadata branches: %w", fixErr)
}

Expand Down
16 changes: 8 additions & 8 deletions cmd/entire/cli/integration_test/remote_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,9 +470,9 @@ func TestCloneAndResume_NewSessionPushAppends(t *testing.T) {
// P1 -- Non-Fast-Forward Recovery
// =============================================================================

// TestConcurrentPush_SecondPusherMergesAndRetries verifies that when two clones
// push to the same remote, the second pusher fetches, merges, and retries.
func TestConcurrentPush_SecondPusherMergesAndRetries(t *testing.T) {
// TestConcurrentPush_SecondPusherRebasesAndRetries verifies that when two clones
// push to the same remote, the second pusher fetches, rebases, and retries.
func TestConcurrentPush_SecondPusherRebasesAndRetries(t *testing.T) {
t.Parallel()
env := NewFeatureBranchEnv(t)

Expand All @@ -497,7 +497,7 @@ func TestConcurrentPush_SecondPusherMergesAndRetries(t *testing.T) {
// A pushes first (should succeed cleanly)
cloneA.RunPrePush("origin")

// B pushes second (will get non-fast-forward, should fetch+merge+retry)
// B pushes second (will get non-fast-forward, should fetch+rebase+retry)
cloneB.RunPrePush("origin")

// Remote should have BOTH checkpoints
Expand All @@ -511,11 +511,11 @@ func TestConcurrentPush_SecondPusherMergesAndRetries(t *testing.T) {
t.Errorf("remote should have checkpoint from clone B: %s", checkpointB)
}

// Verify B's local metadata branch has a merge commit with 2 parents.
// This confirms the fetch+merge+retry path was taken.
// Verify B's local metadata branch tip has exactly 1 parent (linear rebase, not merge).
// This confirms the fetch+rebase+retry path was taken.
parentCount := cloneB.GetBranchTipParentCount(paths.MetadataBranchName)
if parentCount != 2 {
t.Errorf("clone B's metadata branch tip should have 2 parents (merge commit), got %d", parentCount)
if parentCount != 1 {
t.Errorf("clone B's metadata branch tip should have 1 parent (rebased), got %d", parentCount)
}
}

Expand Down
22 changes: 12 additions & 10 deletions cmd/entire/cli/strategy/metadata_reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,10 @@ import (
// disconnectedOnce ensures the disconnection warning runs at most once per process.
var disconnectedOnce sync.Once //nolint:gochecknoglobals // intentional per-process gate

// IsMetadataDisconnected checks whether local and remote entire/checkpoints/v1
// branches exist but share no common ancestor (the "empty-orphan bug").
// 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) {
// IsMetadataDisconnected checks whether the local metadata branch
// and the provided fetched or remote-tracking ref exist but share no common
// ancestor.
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 +36,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 +72,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 @@ -100,7 +97,13 @@ func WarnIfMetadataDisconnected() {
//
// 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 {
// The remote ref can be either a remote-tracking ref or a temporary fetched ref.
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 @@ -113,7 +116,6 @@ func ReconcileDisconnectedMetadataBranch(ctx context.Context, repo *git.Reposito
}

// Check remote-tracking branch
remoteRefName := plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
remoteRef, err := repo.Reference(remoteRefName, true)
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil // No remote branch — nothing to reconcile
Expand Down
32 changes: 18 additions & 14 deletions cmd/entire/cli/strategy/metadata_reconcile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import (
"github.com/stretchr/testify/require"
)

func metadataOriginRemoteRef() plumbing.ReferenceName {
return plumbing.NewRemoteReferenceName("origin", paths.MetadataBranchName)
}

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

Expand Down Expand Up @@ -58,7 +62,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, metadataOriginRemoteRef(), io.Discard); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
Expand All @@ -76,7 +80,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, metadataOriginRemoteRef(), io.Discard); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
Expand All @@ -98,7 +102,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, metadataOriginRemoteRef(), io.Discard); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
Expand Down Expand Up @@ -139,7 +143,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, metadataOriginRemoteRef(), io.Discard); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
Expand Down Expand Up @@ -186,7 +190,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, metadataOriginRemoteRef(), io.Discard); err != nil {
t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err)
}

Expand Down Expand Up @@ -297,7 +301,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, metadataOriginRemoteRef(), io.Discard); err != nil {
t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err)
}

Expand Down Expand Up @@ -416,7 +420,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, metadataOriginRemoteRef())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -437,7 +441,7 @@ func TestIsMetadataDisconnected_NoLocal(t *testing.T) {
}

// No local branch → false
disconnected, err := IsMetadataDisconnected(context.Background(), repo)
disconnected, err := IsMetadataDisconnected(context.Background(), repo, metadataOriginRemoteRef())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -461,7 +465,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, metadataOriginRemoteRef())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -503,7 +507,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, metadataOriginRemoteRef())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -538,7 +542,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, metadataOriginRemoteRef())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand Down Expand Up @@ -593,7 +597,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, metadataOriginRemoteRef(), io.Discard); err != nil {
t.Fatalf("ReconcileDisconnectedMetadataBranch() failed: %v", err)
}

Expand Down Expand Up @@ -693,7 +697,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, metadataOriginRemoteRef(), 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 +747,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, metadataOriginRemoteRef(), io.Discard)
require.NoError(t, err)

// Verify merged tree: should have remote data + first local checkpoint,
Expand Down
Loading
Loading