|
| 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