Add `entire doctor migrate-checkpoints` (git-branch → git-refs) · Entire

Add entire doctor migrate-checkpoints (git-branch → git-refs)

5ded4dbmain· Soph·2w ago·5 files·+437 added/-24 removed

Convert checkpoints on the entire/checkpoints/v1 branch into per-checkpoint refs (refs/entire/checkpoints//), the git-refs store's layout.

checkpoint.MigrateBranchToRefs walks the v1 branch tip and, for each checkpoint, wraps its CURRENT subtree object in a fresh commit and points the ref at it — existing branch commits are not remapped. Since the git-refs ref tree is the branch's / subtree byte-for-byte, a migrated checkpoint reads identically under either backend. It is idempotent: a checkpoint whose ref already carries the same tree is skipped, and a changed checkpoint re-migrates by fast-forward (new commit parents on the existing ref, so no history is lost). New/advanced refs are enqueued for push; the function itself never pushes.

The doctor migrate-checkpoints command reports migrated/skipped/total and, per the requested policy, only pushes when it can prompt: interactively it asks whether to push now; non-interactively it never pushes (refs stay queued and flush on the next push once git-refs is primary). --dry-run reports what would change without writing refs.

Push reuse: the git-refs pre-push queue-flush is extracted into strategy.flushCheckpointRefsQueue (shared by the fail-soft pre-push path and the new error-surfacing strategy.PushMigratedCheckpointRefs), so the "push now" option goes through the exact same fast-forward + fetch/replay logic.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

Sessions

ceed5d17e529View transcript

Changes

5

package checkpoint

import (
    "context"
    "errors"
    "fmt"
    "log/slog"

git "github.com/go-git/go-git/v6"
    "github.com/go-git/go-git/v6/plumbing"

"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
    "github.com/entireio/cli/cmd/entire/cli/logging"
)

// MigrateResult summarizes a git-branch → git-refs checkpoint migration.
type MigrateResult struct {
    // Total is the number of checkpoints found on the v1 branch.
    Total int
    // Migrated lists the checkpoints whose ref was newly written or advanced.
    Migrated []id.CheckpointID
    // Skipped counts checkpoints already up to date (idempotent no-ops).
    Skipped int
}

// MigrateBranchToRefs converts every checkpoint stored on the git-branch v1
// branch (entire/checkpoints/v1) into a per-checkpoint ref under
// refs/entire/checkpoints/<shard>/<id> — the layout the git-refs store uses.
func MigrateBranchToRefs(ctx context.Context, repo *git.Repository, dryRun bool) (MigrateResult, error) {
    var result MigrateResult

branch := NewGitStore(repo, DefaultV1Refs())
tree, err := branch.getSessionsBranchTree()
    if err != nil {
        if errors.Is(err, plumbing.ErrReferenceNotFound) {
            // No v1 branch locally or on origin → nothing to migrate.
            return result, nil
        }
        return result, fmt.Errorf("read v1 checkpoint branch: %w", err)
    }

refsStore := newGitRefsStore(repo)
    authorName, authorEmail := GetGitAuthorFromRepo(repo)

walkErr := WalkCheckpointShards(ctx, repo, tree, func(cid id.CheckpointID, cpTreeHash plumbing.Hash) error {
        if err := ctx.Err(); err != nil {
            return err //nolint:wrapcheck // propagate context cancellation
        }
        result.Total++

refName, err := RefName(cid)
        if err != nil {
            // A malformed id on the branch can't map to a ref; skip it rather
            // than aborting the whole migration.
            logging.Warn(ctx, "migrate: skipping checkpoint with unmappable id",
                slog.String("id", cid.String()), slog.String("error", err.Error()))
            return nil
        }

// Resolve the existing ref once: it drives both the idempotency check and
        // the parent of the new commit.
        parent := plumbing.ZeroHash
        if existing, err := repo.Reference(refName, true); err == nil {
            parent = existing.Hash()
            if commit, cerr := repo.CommitObject(parent); cerr == nil && commit.TreeHash == cpTreeHash {
                result.Skipped++
                return nil
            }
        }

if dryRun {
            result.Migrated = append(result.Migrated, cid)
            return nil
        }

// Wrap the checkpoint's current subtree in a fresh commit — parenting on
        // the existing ref when present (re-migration fast-forwards) or as an
        // orphan for a brand-new ref — then point the ref at it and enqueue it.
        msg := fmt.Sprintf("Import checkpoint %s (migrated from git-branch)", cid)
        commitHash, err := CreateCommit(ctx, repo, cpTreeHash, parent, msg, authorName, authorEmail)
        if err != nil {
            return fmt.Errorf("commit checkpoint %s: %w", cid, err)
        }
        if err := refsStore.setRef(ctx, cid, commitHash); err != nil {
            return fmt.Errorf("set ref for checkpoint %s: %w", cid, err)
        }
        result.Migrated = append(result.Migrated, cid)
        return nil
    })
    if walkErr != nil {
        return result, fmt.Errorf("walk v1 checkpoints: %w", walkErr)
    }
    return result, nil
}
func TestMigrateBranchToRefs(t *testing.T) {
    t.Parallel()
    repo, _ := setupBranchTestRepo(t)
    ctx := context.Background()
    branch := NewGitStore(repo, DefaultV1Refs())

cid1 := id.MustCheckpointID("a1b2c3d4e5f6")
    cid2 := id.MustCheckpointID("b2c3d4e5f6a1")
    seedBranchCheckpoint(t, branch, cid1, "s1")
    seedBranchCheckpoint(t, branch, cid2, "s2")

result, err := MigrateBranchToRefs(ctx, repo, false)
    require.NoError(t, err)
    assert.Equal(t, 2, result.Total)
    assert.Len(t, result.Migrated, 2)
    assert.Equal(t, 0, result.Skipped)

// Each checkpoint now has a ref whose commit tree IS the branch subtree
    // (byte-identical), and it reads back through the git-refs store.
    branchTree, err := branch.getSessionsBranchTree()
    require.NoError(t, err)
    refsStore := newGitRefsStore(repo)
    for _, cid := range []id.CheckpointID{cid1, cid2} {
        commit, err := repo.CommitObject(refHash(t, repo, cid))
        require.NoError(t, err)

branchSub, err := refsStore.subtreeObjAt(branchTree.Hash, cid.Path())
        require.NoError(t, err)
        require.NotNil(t, branchSub)
        assert.Equal(t, branchSub.Hash, commit.TreeHash,
            "ref tree must be the branch subtree, byte-identical")

// A migration commit wraps the tree with no parent (orphan).
        assert.Empty(t, commit.ParentHashes, "first migration commit is an orphan")

summary, err := refsStore.Read(ctx, cid)
        require.NoError(t, err)
        require.NotNil(t, summary, "migrated checkpoint should read via git-refs")
        assert.Equal(t, cid, summary.CheckpointID)
    }

// Idempotent: a second run skips everything and leaves the refs untouched.
    before := map[string]plumbing.Hash{cid1.String(): refHash(t, repo, cid1), cid2.String(): refHash(t, repo, cid2)}
    result2, err := MigrateBranchToRefs(ctx, repo, false)
    require.NoError(t, err)
    assert.Equal(t, 2, result2.Total)
    assert.Empty(t, result2.Migrated, "nothing to migrate on a repeat run")
    assert.Equal(t, 2, result2.Skipped)
    assert.Equal(t, before[cid1.String()], refHash(t, repo, cid1), "idempotent re-run must not move refs")
    assert.Equal(t, before[cid2.String()], refHash(t, repo, cid2))
}
func TestMigrateBranchToRefs_AdvancesOnBranchChange(t *testing.T) {
    t.Parallel()
    repo, _ := setupBranchTestRepo(t)
    ctx := context.Background()
    branch := NewGitStore(repo, DefaultV1Refs())
    cid := id.MustCheckpointID("a1b2c3d4e5f6")

seedBranchCheckpoint(t, branch, cid, "s1")
    _, err := MigrateBranchToRefs(ctx, repo, false)
    require.NoError(t, err)
    first := refHash(t, repo, cid)

// The branch checkpoint gains a second session (its subtree changes).
    seedBranchCheckpoint(t, branch, cid, "s2")

result, err := MigrateBranchToRefs(ctx, repo, false)
    require.NoError(t, err)
    assert.Len(t, result.Migrated, 1, "changed checkpoint is re-migrated")
    assert.Equal(t, 0, result.Skipped)

second := refHash(t, repo, cid)
    assert.NotEqual(t, first, second, "ref advances to the new tree")

// The advance is a fast-forward: the prior migration commit is the parent,
    // so no history is lost.
    commit, err := repo.CommitObject(second)
    require.NoError(t, err)
    require.Len(t, commit.ParentHashes, 1)
    assert.Equal(t, first, commit.ParentHashes[0])
}
func TestMigrateBranchToRefs_DryRunWritesNothing(t *testing.T) {
    t.Parallel()
    repo, _ := setupBranchTestRepo(t)
    ctx := context.Background()
    branch := NewGitStore(repo, DefaultV1Refs())
    cid := id.MustCheckpointID("a1b2c3d4e5f6")
    seedBranchCheckpoint(t, branch, cid, "s1")

result, err := MigrateBranchToRefs(ctx, repo, true)
    require.NoError(t, err)
    assert.Equal(t, 1, result.Total)
    assert.Len(t, result.Migrated, 1, "dry-run reports what would migrate")

refName, err := RefName(cid)
    require.NoError(t, err)
    _, err = repo.Reference(refName, true)
    assert.ErrorIs(t, err, plumbing.ErrReferenceNotFound, "dry-run must not write refs")
}
func TestMigrateBranchToRefs_NoBranchIsNoop(t *testing.T) {
    t.Parallel()
    repo, _ := setupBranchTestRepo(t) // initial commit only; no v1 checkpoint branch yet
    result, err := MigrateBranchToRefs(context.Background(), repo, false)
    require.NoError(t, err)
    assert.Equal(t, 0, result.Total)
    assert.Empty(t, result.Migrated)
}