Add `entire doctor migrate-checkpoints` (git-branch → git-refs) · Entire
Add entire doctor migrate-checkpoints (git-branch → git-refs)
5ded4db→main·
Soph·2w ago·5 files·+437 added/-24 removed
Convert checkpoints on the entire/checkpoints/v1 branch into per-checkpoint refs (refs/entire/checkpoints/
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
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
cmd/entire/cli
checkpoint
Amigrate.go+110
Amigrate_test.go+149
Mdoctor.go+1
Adoctor_migrate.go+114
strategy
Mmanual_commit_push.go+63/-24
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)
}