Skip to content

Commit 6c5bd6c

Browse files
authored
Merge pull request #1611 from entireio/feat/checkpoint-migrate
Add `entire doctor migrate-checkpoints` (git-branch → git-refs)
2 parents d1683dd + b976526 commit 6c5bd6c

7 files changed

Lines changed: 1148 additions & 25 deletions

File tree

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
package checkpoint
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
git "github.com/go-git/go-git/v6"
11+
"github.com/go-git/go-git/v6/plumbing"
12+
"github.com/go-git/go-git/v6/plumbing/filemode"
13+
"github.com/go-git/go-git/v6/plumbing/object"
14+
15+
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
16+
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
17+
"github.com/entireio/cli/cmd/entire/cli/paths"
18+
)
19+
20+
// MigrateResult summarizes a git-branch → git-refs checkpoint migration.
21+
type MigrateResult struct {
22+
// Total is the number of checkpoints found on the v1 branch.
23+
Total int
24+
// Migrated lists the checkpoints whose ref was newly written or advanced.
25+
Migrated []id.CheckpointID
26+
// Skipped counts checkpoints already up to date (idempotent no-ops).
27+
Skipped int
28+
}
29+
30+
// MigrateBranchToRefs converts every checkpoint stored on the git-branch v1
31+
// branch (entire/checkpoints/v1) into a per-checkpoint ref under
32+
// refs/entire/checkpoints/<shard>/<id> — the layout the git-refs store uses.
33+
//
34+
// Each checkpoint's current subtree from the v1 branch tip is wrapped in a
35+
// fresh commit, byte-identical except for the root metadata.json, which is
36+
// normalized for the refs layout (see normalizeMigratedMetadata). Existing
37+
// branch commits are not remapped.
38+
//
39+
// It is idempotent: a ref whose history already contains the normalized tree
40+
// is skipped (even when refs-store writes have advanced the tip past it), and
41+
// a re-run after more branch activity fast-forwards the ref (parenting on the
42+
// existing commit).
43+
//
44+
// Refs are enqueued for push — a failed enqueue is an error, not best-effort —
45+
// including already-imported refs, so a ref left unqueued by a partial earlier
46+
// run still gets pushed. This function does not push. When dryRun is true it
47+
// reports what would change without writing or enqueuing anything.
48+
func MigrateBranchToRefs(ctx context.Context, repo *git.Repository, dryRun bool) (MigrateResult, error) {
49+
var result MigrateResult
50+
51+
branch := NewGitStore(repo, DefaultV1Refs())
52+
tree, err := branch.getSessionsBranchTree()
53+
if err != nil {
54+
if errors.Is(err, plumbing.ErrReferenceNotFound) {
55+
// No v1 branch locally or on origin → nothing to migrate.
56+
return result, nil
57+
}
58+
return result, fmt.Errorf("read v1 checkpoint branch: %w", err)
59+
}
60+
61+
refsStore := newGitRefsStore(repo)
62+
authorName, authorEmail := GetGitAuthorFromRepo(repo)
63+
queue, err := PushQueueForRepo(ctx, repo)
64+
if err != nil {
65+
return result, fmt.Errorf("resolve push queue: %w", err)
66+
}
67+
68+
walkErr := WalkCheckpointShards(ctx, repo, tree, func(cid id.CheckpointID, cpTreeHash plumbing.Hash) error {
69+
if err := ctx.Err(); err != nil {
70+
return err //nolint:wrapcheck // propagate context cancellation
71+
}
72+
result.Total++
73+
74+
migratedTree, err := migratedCheckpointTree(ctx, repo, cid, cpTreeHash, !dryRun)
75+
if err != nil {
76+
return fmt.Errorf("normalize checkpoint %s: %w", cid, err)
77+
}
78+
79+
refName, err := RefName(cid)
80+
if err != nil {
81+
return fmt.Errorf("ref name for checkpoint %s: %w", cid, err)
82+
}
83+
84+
// The existing ref drives the idempotency check and the new commit's
85+
// parent. refBase separates three cases we must not conflate:
86+
// - no ref yet (nil error, zero hash): a brand-new orphan.
87+
// - ref present but its commit object is missing (corrupt or pruned):
88+
// treat as absent and re-import as an orphan rather than parenting on
89+
// a bad hash, which would corrupt the commit graph for fetch+replay.
90+
// - a genuine read failure (transient IO, a concurrent repack): do NOT
91+
// clobber a possibly-valid ref with an orphan; abort this checkpoint
92+
// so an idempotent re-run can retry once the repo is readable again.
93+
parent, _, err := refsStore.refBase(cid)
94+
switch {
95+
case err == nil:
96+
// parent is the ref tip, or zero when the ref is absent.
97+
case errors.Is(err, plumbing.ErrObjectNotFound):
98+
parent = plumbing.ZeroHash
99+
default:
100+
return fmt.Errorf("resolve existing ref for checkpoint %s: %w", cid, err)
101+
}
102+
// This snapshot is already imported when it appears anywhere on the
103+
// ref's first-parent chain: the ref may have advanced past it through
104+
// refs-store writes, and re-wrapping the old snapshot would regress the
105+
// tip.
106+
alreadyImported := treeInRefHistory(repo, parent, migratedTree)
107+
108+
if dryRun {
109+
// Report only what a real run would newly write; an already-imported
110+
// checkpoint is a skip, not a would-migrate. No refs or objects are
111+
// enqueued or written on this path.
112+
if alreadyImported {
113+
result.Skipped++
114+
} else {
115+
result.Migrated = append(result.Migrated, cid)
116+
}
117+
return nil
118+
}
119+
120+
if alreadyImported {
121+
// The snapshot is on the ref, but a prior run may have written the
122+
// ref and then failed before enqueuing it (an Enqueue error, or a
123+
// crash between setRef and Enqueue), leaving it queued for a push
124+
// that never comes — and every later run would skip it here. Enqueue
125+
// unconditionally so the "queued for push" contract survives a
126+
// partial earlier run; duplicates collapse on Drain and an
127+
// already-pushed ref is a no-op on the next push.
128+
if err := queue.Enqueue(refName); err != nil {
129+
return fmt.Errorf("enqueue checkpoint %s for push: %w", cid, err)
130+
}
131+
result.Skipped++
132+
return nil
133+
}
134+
135+
msg := fmt.Sprintf("Import checkpoint %s (migrated from git-branch)", cid)
136+
commitHash, err := CreateCommit(ctx, repo, migratedTree, parent, msg, authorName, authorEmail)
137+
if err != nil {
138+
return fmt.Errorf("commit checkpoint %s: %w", cid, err)
139+
}
140+
if err := refsStore.setRef(ctx, cid, commitHash); err != nil {
141+
return fmt.Errorf("set ref for checkpoint %s: %w", cid, err)
142+
}
143+
// setRef's own enqueue is best-effort (a condensation write must not
144+
// fail on it); the migration's queued-for-push contract needs a
145+
// guaranteed one. Duplicates collapse on Drain.
146+
if err := queue.Enqueue(refName); err != nil {
147+
return fmt.Errorf("enqueue checkpoint %s for push: %w", cid, err)
148+
}
149+
result.Migrated = append(result.Migrated, cid)
150+
return nil
151+
})
152+
if walkErr != nil {
153+
return result, fmt.Errorf("walk v1 checkpoints: %w", walkErr)
154+
}
155+
return result, nil
156+
}
157+
158+
// treeInRefHistory reports whether any commit on the first-parent chain
159+
// starting at tip carries the given tree.
160+
func treeInRefHistory(repo *git.Repository, tip, tree plumbing.Hash) bool {
161+
for h := tip; h != plumbing.ZeroHash; {
162+
commit, err := repo.CommitObject(h)
163+
if err != nil {
164+
return false
165+
}
166+
if commit.TreeHash == tree {
167+
return true
168+
}
169+
if len(commit.ParentHashes) == 0 {
170+
return false
171+
}
172+
h = commit.ParentHashes[0]
173+
}
174+
return false
175+
}
176+
177+
// migratedCheckpointTree returns the branch subtree with its root metadata.json
178+
// normalized for the refs layout — unchanged when already normalized or absent.
179+
//
180+
// When persist is false (dry-run) it computes the resulting tree hash WITHOUT
181+
// writing the normalized blob or tree into the object store. git object hashes
182+
// are content-addressed, so the hash returned is byte-identical to the one the
183+
// persisting path produces — idempotency reporting stays exact while a dry-run
184+
// leaves no loose objects behind.
185+
func migratedCheckpointTree(ctx context.Context, repo *git.Repository, cid id.CheckpointID, cpTreeHash plumbing.Hash, persist bool) (plumbing.Hash, error) {
186+
subtree, err := repo.TreeObject(cpTreeHash)
187+
if err != nil {
188+
return plumbing.ZeroHash, fmt.Errorf("read checkpoint tree: %w", err)
189+
}
190+
metadataFile, err := subtree.File(paths.MetadataFileName)
191+
if err != nil {
192+
if errors.Is(err, object.ErrFileNotFound) {
193+
return cpTreeHash, nil
194+
}
195+
return plumbing.ZeroHash, fmt.Errorf("read metadata.json: %w", err)
196+
}
197+
raw, err := metadataFile.Contents()
198+
if err != nil {
199+
return plumbing.ZeroHash, fmt.Errorf("read metadata.json: %w", err)
200+
}
201+
202+
normalized, changed, err := normalizeMigratedMetadata([]byte(raw), cid)
203+
if err != nil {
204+
return plumbing.ZeroHash, err
205+
}
206+
if !changed {
207+
return cpTreeHash, nil
208+
}
209+
210+
if !persist {
211+
blobHash, err := hashBlob(repo, normalized)
212+
if err != nil {
213+
return plumbing.ZeroHash, fmt.Errorf("hash normalized metadata.json: %w", err)
214+
}
215+
return hashRootFileSwap(repo, subtree, paths.MetadataFileName, blobHash)
216+
}
217+
218+
blobHash, err := CreateBlobFromContent(repo, normalized)
219+
if err != nil {
220+
return plumbing.ZeroHash, fmt.Errorf("write normalized metadata.json: %w", err)
221+
}
222+
newTree, err := ApplyTreeChanges(ctx, repo, cpTreeHash, []TreeChange{{
223+
Path: paths.MetadataFileName,
224+
Entry: &object.TreeEntry{Name: paths.MetadataFileName, Mode: filemode.Regular, Hash: blobHash},
225+
}})
226+
if err != nil {
227+
return plumbing.ZeroHash, fmt.Errorf("build normalized checkpoint tree: %w", err)
228+
}
229+
return newTree, nil
230+
}
231+
232+
// hashBlob encodes content as a git blob and returns its hash without storing
233+
// it — mirroring CreateBlobFromContent's encoding so the two hash identically.
234+
func hashBlob(repo *git.Repository, content []byte) (plumbing.Hash, error) {
235+
obj := repo.Storer.NewEncodedObject()
236+
obj.SetType(plumbing.BlobObject)
237+
obj.SetSize(int64(len(content)))
238+
w, err := obj.Writer()
239+
if err != nil {
240+
return plumbing.ZeroHash, fmt.Errorf("open blob writer: %w", err)
241+
}
242+
if _, err := w.Write(content); err != nil {
243+
_ = w.Close()
244+
return plumbing.ZeroHash, fmt.Errorf("write blob: %w", err)
245+
}
246+
if err := w.Close(); err != nil {
247+
return plumbing.ZeroHash, fmt.Errorf("close blob writer: %w", err)
248+
}
249+
return obj.Hash(), nil
250+
}
251+
252+
// hashRootFileSwap returns the hash of subtree with one root-level file entry
253+
// replaced by blobHash, without storing the new tree. It mirrors ApplyTreeChanges
254+
// + storeTree for a single root-level file (force Regular mode, then
255+
// sortTreeEntries before encoding) so the hash matches the persisting path.
256+
func hashRootFileSwap(repo *git.Repository, subtree *object.Tree, name string, blobHash plumbing.Hash) (plumbing.Hash, error) {
257+
entries := make([]object.TreeEntry, len(subtree.Entries))
258+
copy(entries, subtree.Entries)
259+
swapped := false
260+
for i := range entries {
261+
if entries[i].Name == name {
262+
entries[i] = object.TreeEntry{Name: name, Mode: filemode.Regular, Hash: blobHash}
263+
swapped = true
264+
break
265+
}
266+
}
267+
if !swapped {
268+
// The caller only reaches here after reading name from this same tree.
269+
return plumbing.ZeroHash, fmt.Errorf("%s not found in checkpoint tree", name)
270+
}
271+
sortTreeEntries(entries)
272+
obj := repo.Storer.NewEncodedObject()
273+
if err := (&object.Tree{Entries: entries}).Encode(obj); err != nil {
274+
return plumbing.ZeroHash, fmt.Errorf("encode dry-run tree: %w", err)
275+
}
276+
return obj.Hash(), nil
277+
}
278+
279+
// normalizeMigratedMetadata rewrites a checkpoint's root metadata.json for the
280+
// refs layout: it drops the legacy checkpoint_version field and strips the
281+
// "/<shard>/<id>" prefix from sessions[] paths. Any session string value under
282+
// the prefix is rebased, so path fields added by other CLI versions are covered
283+
// without naming them. The raw JSON is edited in place so fields this CLI
284+
// doesn't model are preserved. changed is false when the metadata already
285+
// matches the refs layout.
286+
func normalizeMigratedMetadata(raw []byte, cid id.CheckpointID) (normalized []byte, changed bool, err error) {
287+
var doc map[string]any
288+
if err := json.Unmarshal(raw, &doc); err != nil {
289+
return nil, false, fmt.Errorf("parse metadata.json: %w", err)
290+
}
291+
292+
if _, ok := doc["checkpoint_version"]; ok {
293+
delete(doc, "checkpoint_version")
294+
changed = true
295+
}
296+
297+
branchPrefix := "/" + cid.Path()
298+
if sessions, ok := doc["sessions"].([]any); ok {
299+
for _, entry := range sessions {
300+
session, ok := entry.(map[string]any)
301+
if !ok {
302+
continue
303+
}
304+
for field, raw := range session {
305+
value, ok := raw.(string)
306+
if !ok {
307+
continue
308+
}
309+
if rest, found := strings.CutPrefix(value, branchPrefix); found && strings.HasPrefix(rest, "/") {
310+
session[field] = rest
311+
changed = true
312+
}
313+
}
314+
}
315+
}
316+
if !changed {
317+
return nil, false, nil
318+
}
319+
320+
normalized, err = jsonutil.MarshalIndentWithNewline(doc, "", " ")
321+
if err != nil {
322+
return nil, false, fmt.Errorf("encode metadata.json: %w", err)
323+
}
324+
return normalized, true, nil
325+
}

0 commit comments

Comments
 (0)