Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions cmd/entire/cli/checkpoint/v2_generation.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,13 +529,21 @@ func (s *V2GitStore) RotateCurrentGenerationIfNeeded(ctx context.Context, maxChe
return "", false, fmt.Errorf("rotation: failed to create archive commit: %w", err)
}

// Create fresh orphan /full/current (empty tree, no generation.json).
// Build a fresh /full/current empty tree, parented on /full/root so the
// new generation shares ancestry with every other /full/* ref. /full/root
// is constructed deterministically — different machines produce the same
// SHA, so this does not introduce a new source of cross-client divergence.
emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry))
if err != nil {
return "", false, fmt.Errorf("rotation: failed to build empty tree: %w", err)
}

orphanCommitHash, err := CreateCommit(ctx, s.repo, emptyTreeHash, plumbing.ZeroHash, "Start generation", authorName, authorEmail)
rootHash, err := s.ensureV2FullRoot(ctx)
if err != nil {
return "", false, fmt.Errorf("rotation: failed to ensure /full/root: %w", err)
}

orphanCommitHash, err := CreateCommit(ctx, s.repo, emptyTreeHash, rootHash, "Start generation", authorName, authorEmail)
if err != nil {
return "", false, fmt.Errorf("rotation: failed to create orphan commit: %w", err)
}
Expand Down
27 changes: 25 additions & 2 deletions cmd/entire/cli/checkpoint/v2_generation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,25 @@ func TestListArchivedGenerations_ExcludesCurrent(t *testing.T) {
assert.Equal(t, []string{"0000000000001"}, archived)
}

// /full/root is a shared anchor commit, not an archived generation. Cleanup
// must never see it as a deletable candidate — its deletion would break
// reachability of every generation parented on it.
func TestListArchivedGenerations_ExcludesRoot(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
store := NewV2GitStore(repo, "origin")

_, err := store.ensureV2FullRoot(context.Background())
require.NoError(t, err)

createArchivedRef(t, repo, 1)

archived, err := store.ListArchivedGenerations()
require.NoError(t, err)
assert.Equal(t, []string{"0000000000001"}, archived,
"/full/root must be excluded from archive listing")
}

func TestNextGenerationNumber_NoArchives(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
Expand Down Expand Up @@ -440,8 +459,12 @@ func TestRotateGeneration_ArchivesCurrentAndCreatesNewOrphan(t *testing.T) {
freshCommit, err := repo.CommitObject(fullRef.Hash())
require.NoError(t, err)

// Fresh commit should be an orphan (no parents)
assert.Empty(t, freshCommit.ParentHashes, "fresh /full/current should be an orphan commit")
// Fresh commit should be parented on /full/root so generation refs share a
// common ancestor in the commit graph. /full/root must exist locally.
rootRef, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRootRefName), true)
require.NoError(t, err)
require.Equal(t, []plumbing.Hash{rootRef.Hash()}, freshCommit.ParentHashes,
"fresh /full/current should be parented on /full/root")

// Fresh tree should be empty (no generation.json, no shard directories)
freshTree, err := freshCommit.Tree()
Expand Down
79 changes: 79 additions & 0 deletions cmd/entire/cli/checkpoint/v2_root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package checkpoint

import (
"context"
"fmt"
"time"

"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
)

// The /full/root commit is constructed with fixed inputs (empty tree, fixed
// author, fixed Unix-epoch timestamp, fixed message, no signature) so every
// client produces an identical SHA. That lets concurrent first-time creation
// across machines converge — both clients write the same bytes, so the push
// is a no-op rather than a conflict. Don't change these inputs without
// understanding the migration consequence: clients on different versions
// would produce different SHAs.
const (
v2FullRootAuthorName = "Entire Checkpoints"
v2FullRootAuthorEmail = "checkpoints@entire.io"
v2FullRootMessage = "Entire checkpoints v2 root\n"
)

func buildV2FullRootCommit(ctx context.Context, repo *git.Repository) (plumbing.Hash, error) {
emptyTreeHash, err := BuildTreeFromEntries(ctx, repo, make(map[string]object.TreeEntry))
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("build empty tree for /full/root: %w", err)
}

sig := object.Signature{
Name: v2FullRootAuthorName,
Email: v2FullRootAuthorEmail,
When: time.Unix(0, 0).UTC(),
}

commit := &object.Commit{
TreeHash: emptyTreeHash,
Author: sig,
Committer: sig,
Message: v2FullRootMessage,
}
// Don't sign — signatures are non-deterministic and would break SHA convergence.

obj := repo.Storer.NewEncodedObject()
if err := commit.Encode(obj); err != nil {
return plumbing.ZeroHash, fmt.Errorf("encode /full/root commit: %w", err)
}

hash, err := repo.Storer.SetEncodedObject(obj)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("store /full/root commit: %w", err)
}
return hash, nil
}

// ensureV2FullRoot returns the local /full/root commit hash, building and
// setting the ref if it does not yet exist. Local-only; remote publication
// happens through the push pipeline.
func (s *V2GitStore) ensureV2FullRoot(ctx context.Context) (plumbing.Hash, error) {
refName := plumbing.ReferenceName(paths.V2FullRootRefName)

if ref, err := s.repo.Reference(refName, true); err == nil {
return ref.Hash(), nil
}
Comment thread
pfleidi marked this conversation as resolved.

hash, err := buildV2FullRootCommit(ctx, s.repo)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("ensure /full/root: %w", err)
}

ref := plumbing.NewHashReference(refName, hash)
if err := s.repo.Storer.SetReference(ref); err != nil {
return plumbing.ZeroHash, fmt.Errorf("set local /full/root: %w", err)
}
return hash, nil
}
90 changes: 90 additions & 0 deletions cmd/entire/cli/checkpoint/v2_root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package checkpoint

import (
"context"
"testing"

"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/go-git/go-git/v6/plumbing"
"github.com/stretchr/testify/require"
)

// Pinned SHA for the deterministic /full/root commit. Any accidental change
// to the inputs in v2_root.go (author, time, message, encoding) flips this.
// Updating it on purpose creates a cross-version migration problem — old
// and new clients would produce different SHAs and the race-resolves-to-no-op
// property would no longer hold.
const expectedV2FullRootHash = "c095af40b171ff4c3c4a781abacd39aa499e183b"

func TestBuildV2FullRootCommit_WellKnownSHA(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)

hash, err := buildV2FullRootCommit(context.Background(), repo)
require.NoError(t, err)

require.Equal(t, expectedV2FullRootHash, hash.String(),
"deterministic root commit SHA changed — see comment on expectedV2FullRootHash")
}

func TestBuildV2FullRootCommit_AcrossDifferentRepos(t *testing.T) {
t.Parallel()
repoA := initTestRepo(t)
repoB := initTestRepo(t)

hashA, err := buildV2FullRootCommit(context.Background(), repoA)
require.NoError(t, err)

hashB, err := buildV2FullRootCommit(context.Background(), repoB)
require.NoError(t, err)

require.Equal(t, hashA, hashB,
"different repos must produce identical root commit SHA")
}

func TestEnsureV2FullRoot_CreatesRefAndCommit(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
store := NewV2GitStore(repo, "origin")

hash, err := store.ensureV2FullRoot(context.Background())
require.NoError(t, err)
require.Equal(t, expectedV2FullRootHash, hash.String())

ref, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRootRefName), true)
require.NoError(t, err)
require.Equal(t, hash, ref.Hash(),
"local ref must point at the deterministic commit")
}

func TestEnsureV2FullRoot_IsIdempotent(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
store := NewV2GitStore(repo, "origin")

first, err := store.ensureV2FullRoot(context.Background())
require.NoError(t, err)

second, err := store.ensureV2FullRoot(context.Background())
require.NoError(t, err)

require.Equal(t, first, second,
"repeated calls must return the same hash without changing the ref")
}

func TestEnsureV2FullRoot_PreservesPreexistingRef(t *testing.T) {
t.Parallel()
repo := initTestRepo(t)
store := NewV2GitStore(repo, "origin")

Comment thread
Soph marked this conversation as resolved.
Outdated
first, err := store.ensureV2FullRoot(context.Background())
require.NoError(t, err)

second, err := store.ensureV2FullRoot(context.Background())
require.NoError(t, err)
require.Equal(t, first, second)

ref, err := repo.Reference(plumbing.ReferenceName(paths.V2FullRootRefName), true)
require.NoError(t, err)
require.Equal(t, first, ref.Hash())
}
19 changes: 16 additions & 3 deletions cmd/entire/cli/checkpoint/v2_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"sync"

"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
Expand Down Expand Up @@ -87,21 +88,33 @@ func (s *V2GitStore) wrapWithFetcher(ctx context.Context, tree *object.Tree) *Fe
return NewFetchingTree(ctx, tree, s.repo.Storer, s.blobFetcher)
}

// ensureRef ensures that a custom ref exists, creating an orphan commit
// with an empty tree if it does not.
// ensureRef ensures that a custom ref exists, creating an initial commit
// with an empty tree if it does not. For /full/current, the initial commit
// is parented on /full/root so generation refs share a common ancestor in
// the commit graph. /main keeps an orphan initial commit (different ref
// namespace, no shared ancestry expected).
func (s *V2GitStore) ensureRef(ctx context.Context, refName plumbing.ReferenceName) error {
_, err := s.repo.Reference(refName, true)
if err == nil {
return nil // Already exists
}

parentHash := plumbing.ZeroHash
if refName == plumbing.ReferenceName(paths.V2FullCurrentRefName) {
rootHash, rootErr := s.ensureV2FullRoot(ctx)
if rootErr != nil {
return fmt.Errorf("ensure /full/root before creating /full/current: %w", rootErr)
}
parentHash = rootHash
}

emptyTreeHash, err := BuildTreeFromEntries(ctx, s.repo, make(map[string]object.TreeEntry))
if err != nil {
return fmt.Errorf("failed to build empty tree: %w", err)
}

authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
commitHash, err := CreateCommit(ctx, s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize v2 ref", authorName, authorEmail)
commitHash, err := CreateCommit(ctx, s.repo, emptyTreeHash, parentHash, "Initialize v2 ref", authorName, authorEmail)
if err != nil {
return fmt.Errorf("failed to create initial commit: %w", err)
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/paths/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ const (
// V2FullCurrentRefName stores the active generation of raw transcripts.
V2FullCurrentRefName = "refs/entire/checkpoints/v2/full/current"

// V2FullRootRefName anchors every /full/* archived generation as a shared
// permanent root commit. Constructed deterministically (fixed author, time,
// message, empty tree) so every client independently produces the same SHA.
// Created lazily on first rotation; fetched from remote when available.
Comment thread
Soph marked this conversation as resolved.
Outdated
V2FullRootRefName = "refs/entire/checkpoints/v2/full/root"

// V2FullRefPrefix is the common prefix for all /full/* refs (current + archived).
V2FullRefPrefix = "refs/entire/checkpoints/v2/full/"

Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/strategy/push_v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -907,8 +907,14 @@ func printV2PushFailures(ctx context.Context, target string, successfulRefs []pl

func v2RefsToPush(repo *git.Repository) []plumbing.ReferenceName {
var refs []plumbing.ReferenceName
// /full/root is pushed first. It is constructed deterministically so two
// clients pushing it concurrently send identical bytes — collisions
// resolve to a no-op rather than a non-fast-forward conflict. Pushing it
// ahead of /full/current means new clones can fetch the anchor before
// any descendant ref.
for _, refName := range []plumbing.ReferenceName{
plumbing.ReferenceName(paths.V2MainRefName),
plumbing.ReferenceName(paths.V2FullRootRefName),
plumbing.ReferenceName(paths.V2FullCurrentRefName),
Comment thread
Soph marked this conversation as resolved.
Outdated
} {
if _, err := repo.Reference(refName, true); err == nil {
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/strategy/push_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ func TestPushV2Refs_SkipsUnrecordedArchiveRefs(t *testing.T) {
require.Error(t, err, "unrecorded older archived generation should not be pushed")

assert.Contains(t, output, "[entire] Syncing and pushing v2 checkpoints...")
assert.Contains(t, output, "[entire] Pushing v2/main, v2/full/current...")
assert.Contains(t, output, "[entire] Pushing v2/main, v2/full/root, v2/full/current...")
assert.Contains(t, output, "[entire] All v2 checkpoints pushed")
Comment thread
Soph marked this conversation as resolved.
assert.NotContains(t, output, "[entire] Successfully pushed", "successful refs should only be listed on partial failure")
assert.NotContains(t, output, "Pushing v2/main to", "per-ref progress should stay quiet")
Expand Down
Loading