migrate: --dry-run no longer writes loose git objects · Entire

migrate: --dry-run no longer writes loose git objects

80f6806→main·

Soph·1w ago·2 files·+108 added/-2 removed

migratedCheckpointTree ran before the dry-run guard and, for any checkpoint whose metadata needs normalizing, called CreateBlobFromContent + ApplyTreeChanges — both persist via SetEncodedObject. So a "preview" that promises to report "without writing" left a dangling blob + tree per checkpoint in the object store (thousands on a large v1 branch, until a future git gc).

Thread the dry-run flag into migratedCheckpointTree: when not persisting, compute the normalized blob and tree hashes in memory (git hashes are content-addressed, so an object encoded but not stored hashes identically to a persisted one) and return without touching the object store. The persisting path is unchanged. A new test asserts the object count is unchanged across a dry-run, and another migrates for real then dry-runs again and asserts the checkpoint is recognized as already-migrated — pinning the in-memory hash to the persisted tree hash.

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

Sessions

01KWY7CS85ZMCCM8RQEGKHPQAFView transcript

Changes

2

70 unmodified lines

71
72
73
74
74
75
76
77
98 unmodified lines

176
177
178
179
179
180
181
182
183
184
185
186
187
188
18 unmodified lines

207
208
209
210
211
212
213
214
215
216
217
218
219
220
8 unmodified lines

229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281

70 unmodified lines

}
        result.Total++

migratedTree, err := migratedCheckpointTree(ctx, repo, cid, cpTreeHash)
        migratedTree, err := migratedCheckpointTree(ctx, repo, cid, cpTreeHash, !dryRun)
        if err != nil {
            return fmt.Errorf("normalize checkpoint %s: %w", cid, err)
        }
    98 unmodified lines

// migratedCheckpointTree returns the branch subtree with its root metadata.json
// normalized for the refs layout — unchanged when already normalized or absent.
func migratedCheckpointTree(ctx context.Context, repo *git.Repository, cid id.CheckpointID, cpTreeHash plumbing.Hash) (plumbing.Hash, error) {
//
// When persist is false (dry-run) it computes the resulting tree hash WITHOUT
// writing the normalized blob or tree into the object store. git object hashes
// are content-addressed, so the hash returned is byte-identical to the one the
// persisting path produces — idempotency reporting stays exact while a dry-run
// leaves no loose objects behind.
func migratedCheckpointTree(ctx context.Context, repo *git.Repository, cid id.CheckpointID, cpTreeHash plumbing.Hash, persist bool) (plumbing.Hash, error) {
    subtree, err := repo.TreeObject(cpTreeHash)
    if err != nil {
        return plumbing.ZeroHash, fmt.Errorf("read checkpoint tree: %w", err)
    }

if !persist {
        blobHash, err := hashBlob(repo, normalized)
        if err != nil {
            return plumbing.ZeroHash, fmt.Errorf("hash normalized metadata.json: %w", err)
        }
        return hashRootFileSwap(repo, subtree, paths.MetadataFileName, blobHash)
    }

blobHash, err := CreateBlobFromContent(repo, normalized)
    if err != nil {
        return plumbing.ZeroHash, fmt.Errorf("write normalized metadata.json: %w", err)
    }
    return newTree, nil
}

// hashBlob encodes content as a git blob and returns its hash without storing
// it — mirroring CreateBlobFromContent's encoding so the two hash identically.
func hashBlob(repo *git.Repository, content []byte) (plumbing.Hash, error) {
    obj := repo.Storer.NewEncodedObject()
    obj.SetType(plumbing.BlobObject)
    obj.SetSize(int64(len(content)))
    w, err := obj.Writer()
    if err != nil {
        return plumbing.ZeroHash, fmt.Errorf("open blob writer: %w", err)
    }
    if _, err := w.Write(content); err != nil {
        _ = w.Close()
        return plumbing.ZeroHash, fmt.Errorf("write blob: %w", err)
    }
    if err := w.Close(); err != nil {
        return plumbing.ZeroHash, fmt.Errorf("close blob writer: %w", err)
    }
    return obj.Hash(), nil
}

// hashRootFileSwap returns the hash of subtree with one root-level file entry
// replaced by blobHash, without storing the new tree. It mirrors ApplyTreeChanges
// + storeTree for a single root-level file (force Regular mode, then
// sortTreeEntries before encoding) so the hash matches the persisting path.
func hashRootFileSwap(repo *git.Repository, subtree *object.Tree, name string, blobHash plumbing.Hash) (plumbing.Hash, error) {
    entries := make([]object.TreeEntry, len(subtree.Entries))
    copy(entries, subtree.Entries)
    swapped := false
    for i := range entries {
        if entries[i].Name == name {
            entries[i] = object.TreeEntry{Name: name, Mode: filemode.Regular, Hash: blobHash}
            swapped = true
            break
        }
    }
    if !swapped {
        // The caller only reaches here after reading name from this same tree.
        return plumbing.ZeroHash, fmt.Errorf("%s not found in checkpoint tree", name)
    }
    sortTreeEntries(entries)
    obj := repo.Storer.NewEncodedObject()
    if err := (&object.Tree{Entries: entries}).Encode(obj); err != nil {
        return plumbing.ZeroHash, fmt.Errorf("encode dry-run tree: %w", err)
    }
    return obj.Hash(), nil
}

// normalizeMigratedMetadata rewrites a checkpoint's root metadata.json for the
// refs layout: it drops the legacy checkpoint_version field and strips the
// "/<shard>/<id>" prefix from sessions[] paths. Any session string value under

Mcmd/entire/cli/checkpoint/migrate.go+63/-2

224 unmodified lines

225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 7 unmodified lines

250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293

224 unmodified lines

branch := NewGitStore(repo, DefaultV1Refs()) cid := id.MustCheckpointID("a1b2c3d4e5f6") seedBranchCheckpoint(t, branch, cid, "s1") // Legacy metadata so normalization rewrites the tree — the case that used to // persist a blob + tree even under dry-run. mutateBranchCheckpointMetadata(t, repo, cid, func(doc map[string]any) { doc["checkpoint_version"] = "branch-v1" })

before := countObjects(t, repo) 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") assert.Equal(t, before, countObjects(t, repo), "dry-run must not write git objects")

refName, err := RefName(cid) require.NoError(t, err)

assert.Empty(t, queued, "dry-run must not enqueue refs for push") // countObjects returns the number of objects in the repo's object store. func countObjects(t *testing.T, repo *git.Repository) int { t.Helper() iter, err := repo.Storer.IterEncodedObjects(plumbing.AnyObject) require.NoError(t, err) defer iter.Close() n := 0 require.NoError(t, iter.ForEach(func(plumbing.EncodedObject) error { n++ return nil })) return n }

func TestMigrateBranchToRefs_DryRunRecognizesAlreadyMigrated(t *testing.T) { t.Parallel() repo, _ := setupBranchTestRepo(t) ctx := context.Background() branch := NewGitStore(repo, DefaultV1Refs()) cid := id.MustCheckpointID("a1b2c3d4e5f6") seedBranchCheckpoint(t, branch, cid, "s1") mutateBranchCheckpointMetadata(t, repo, cid, func(doc map[string]any) { doc["checkpoint_version"] = "branch-v1" })

// Real migration persists the normalized tree. res, err := MigrateBranchToRefs(ctx, repo, false) require.NoError(t, err) require.Len(t, res.Migrated, 1)

// Dry-run must see it as already migrated — proving the non-persisting hash // computation matches the persisted tree hash byte-for-byte. dry, err := MigrateBranchToRefs(ctx, repo, true) require.NoError(t, err) assert.Empty(t, dry.Migrated, "already-migrated checkpoint is not a would-migrate") assert.Equal(t, 1, dry.Skipped) }

func TestMigrateBranchToRefs_SkipsRefAdvancedPastBranchSnapshot(t *testing.T) { t.Parallel() repo, _ := setupBranchTestRepo(t)