Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fbc8394
Add a migration script
computermode May 22, 2026
1ea7f14
Add v2 checkpoint migration apply mode
computermode May 22, 2026
5ef0e37
Address v2 migration script review comments
computermode May 26, 2026
09dd22e
Write migrated checkpoints as dated v1 commits
computermode May 26, 2026
1cb2ec4
Qualify checkpoint push refspec
computermode May 26, 2026
acbd558
Repair split checkpoint migration rewrites
computermode May 26, 2026
49dede5
Record associated commits in v2 migration
computermode May 26, 2026
f6fa289
Merge branch 'main' of github.com:entireio/cli into tmp-migrate-v2-sc…
pfleidi May 26, 2026
d54434b
Preserve checkpoint commit timestamps
pfleidi May 26, 2026
f513458
Add v2 checkpoint migration command skeleton
pfleidi May 26, 2026
399c909
Migrate v2 checkpoint data through Go stores
pfleidi May 26, 2026
3a20ce9
Expand v2 checkpoint migration tests
pfleidi May 26, 2026
10e1a5b
Merge branch 'main' into tmp-migrate-v2-script
pfleidi May 26, 2026
9df4393
Merge branch 'tmp-migrate-v2-script' of github.com:entireio/cli into …
pfleidi May 26, 2026
81cddb9
Delegate temporary commit creation
pfleidi May 26, 2026
e68ee79
Limit v2 migration discovery to since histories
pfleidi May 26, 2026
f49344e
Scope migrated review status to restored sessions
pfleidi May 27, 2026
6641537
Address v2 migration feedback
pfleidi May 27, 2026
23dc98d
Merge origin/main into tmp-migrate-v2-script-go
pfleidi May 27, 2026
f5ef3ca
Simplify migrate-v2-checkpoints and checkpoint test
pfleidi May 27, 2026
9be5839
Improve v2 checkpoint migration reporting
pfleidi May 27, 2026
ae19515
Address review feedback
pfleidi May 27, 2026
6883a32
Discover v2 orphan checkpoints
pfleidi May 27, 2026
5a3760e
Update validation instructions
pfleidi May 27, 2026
2f140a3
Handle sparse v1 checkpoint sessions
pfleidi May 27, 2026
86f6dbb
Update validation instructions
pfleidi May 27, 2026
15b3e67
Fetch v2 refs before checkpoint migration
pfleidi May 27, 2026
773c304
Preserve v2 checkpoint authors during migration
pfleidi May 28, 2026
3d9c243
Update migration validation runbook
pfleidi May 28, 2026
224fc97
Merge branch 'main' of github.com:entireio/cli into tmp-migrate-v2-sc…
pfleidi May 28, 2026
f1e4ad8
Fetch v1 checkpoints before migration
pfleidi May 28, 2026
620378f
Update migration validation runbook
pfleidi May 28, 2026
e732e93
Merge branch 'main' of entire://aws-us-east-2.entire.io/gh/entireio/c…
pfleidi May 28, 2026
125b214
Disable signing for migrated checkpoint commits
pfleidi May 28, 2026
118296f
Document unsigned migration commits
pfleidi May 28, 2026
0b2a750
Ignore v2 merge commits for session authors
pfleidi May 28, 2026
6850571
Fix migration data preservation gaps
pfleidi May 29, 2026
092de10
Name eligible v2 session struct
pfleidi May 29, 2026
be2f7e8
Fix v2 session author lookup
pfleidi May 29, 2026
8616ee2
Disable signing for migration tool runs
pfleidi May 29, 2026
d6e4e40
Index v2 session authors during migration
pfleidi May 29, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mise.local.toml

# Binary output (only in root)
/entire
/migrate-v2-checkpoints
/vogon
/testreport
/bin
Expand Down
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ linters:
rules:
- path: _test\.go
linters:
- goconst
- gosec
- wrapcheck
- forbidigo
Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ type WriteCommittedOptions struct {
// the original v1 checkpoint time in v2 metadata.
CreatedAt time.Time

// CommitTime is the optional git author/committer timestamp for the
// metadata-branch commit. When zero, writers use the current time.
CommitTime time.Time

// Strategy is the name of the strategy that created this checkpoint
Strategy string

Expand Down
134 changes: 116 additions & 18 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,26 @@ import (
// errStopIteration is used to stop commit iteration early in GetCheckpointAuthor.
var errStopIteration = errors.New("stop iteration")

type commitSigningDisabledContextKey struct{}

// chunkTranscript is an indirection over agent.ChunkTranscript so tests can
// count or intercept chunking calls (e.g., to verify the short-circuit avoids
// re-chunking identical content). Production code paths always use the
// unwrapped function.
var chunkTranscript = agent.ChunkTranscript

// WithCommitSigningDisabled returns a context that prevents metadata branch
// commit signing. Use for replay/migration writes whose author line is sourced
// from historical data rather than the local operator.
func WithCommitSigningDisabled(ctx context.Context) context.Context {
return context.WithValue(ctx, commitSigningDisabledContextKey{}, true)
}

func commitSigningDisabled(ctx context.Context) bool {
disabled, ok := ctx.Value(commitSigningDisabledContextKey{}).(bool)
return ok && disabled
}

// WriteCommitted writes a committed checkpoint to the entire/checkpoints/v1 branch.
// Checkpoints are stored at sharded paths: <id[:2]>/<id[2:]>/
//
Expand Down Expand Up @@ -118,7 +132,11 @@ func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOption
}

commitMsg := s.buildCommitMessage(opts, taskMetadataPath)
newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail)
commitTime := opts.CommitTime
if commitTime.IsZero() {
commitTime = time.Now()
}
newCommitHash, err := s.createCommitAt(ctx, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail, commitTime)
if err != nil {
return err
}
Expand Down Expand Up @@ -363,12 +381,16 @@ func (s *GitStore) writeStandardCheckpointEntries(ctx context.Context, opts Writ
// Build the sessions array
var sessions []SessionFilePaths
if existingSummary != nil {
sessions = make([]SessionFilePaths, max(len(existingSummary.Sessions), sessionIndex+1))
copy(sessions, existingSummary.Sessions)
sessions = append([]SessionFilePaths(nil), existingSummary.Sessions...)
} else {
sessions = make([]SessionFilePaths, 1)
sessions = []SessionFilePaths{}
}
sessions[sessionIndex] = sessionFilePaths
if position := sessionFilePathsPosition(basePath, sessions, sessionIndex); position >= 0 {
sessions[position] = sessionFilePaths
} else {
sessions = append(sessions, sessionFilePaths)
}
sortSessionFilePaths(basePath, sessions)

// Tripwire: an unreproduced production report had session 0 silently
// replaced with a different sessionID's data. The symptom was
Expand Down Expand Up @@ -484,7 +506,7 @@ func (s *GitStore) writeSessionToSubdirectory(ctx context.Context, opts WriteCom
// writeCheckpointSummary writes the root-level CheckpointSummary with aggregated statistics.
// sessions is the complete sessions array (already built by the caller).
func (s *GitStore) writeCheckpointSummary(opts WriteCommittedOptions, basePath string, entries map[string]object.TreeEntry, sessions []SessionFilePaths) error {
checkpointsCount, filesTouched, tokenUsage, err := s.reaggregateFromEntries(basePath, len(sessions), entries)
checkpointsCount, filesTouched, tokenUsage, err := s.reaggregateFromEntries(basePath, sessions, entries)
if err != nil {
return fmt.Errorf("failed to aggregate session stats: %w", err)
}
Expand Down Expand Up @@ -614,44 +636,63 @@ func (s *GitStore) findSessionIndex(ctx context.Context, basePath string, existi
if existingSummary == nil {
return 0
}
for i := range len(existingSummary.Sessions) {
path := fmt.Sprintf("%s%d/%s", basePath, i, paths.MetadataFileName)
usedIndexes := make(map[int]struct{}, len(existingSummary.Sessions))
for summaryIndex, sessionPaths := range existingSummary.Sessions {
sessionIndex, ok := sessionIndexFromFilePaths(basePath, sessionPaths)
if !ok {
sessionIndex = summaryIndex
}
usedIndexes[sessionIndex] = struct{}{}

path := fmt.Sprintf("%s%d/%s", basePath, sessionIndex, paths.MetadataFileName)
entry, exists := entries[path]
if !exists {
continue
}
meta, err := s.readMetadataFromBlob(entry.Hash)
if err != nil {
logging.Warn(ctx, "failed to read session metadata during dedup check",
slog.Int("session_index", i),
slog.Int("session_index", sessionIndex),
slog.String("session_id", sessionID),
slog.String("error", err.Error()),
)
continue
}
if meta.SessionID == sessionID {
return i
return sessionIndex
}
}
for sessionIndex := 0; ; sessionIndex++ {
if _, used := usedIndexes[sessionIndex]; used {
continue
}
if sessionPathHasEntries(basePath, sessionIndex, entries) {
continue
}
return sessionIndex
}
return len(existingSummary.Sessions)
}

// reaggregateFromEntries reads all session metadata from the entries map and
// reaggregates CheckpointsCount, FilesTouched, and TokenUsage.
func (s *GitStore) reaggregateFromEntries(basePath string, sessionCount int, entries map[string]object.TreeEntry) (int, []string, *agent.TokenUsage, error) {
func (s *GitStore) reaggregateFromEntries(basePath string, sessions []SessionFilePaths, entries map[string]object.TreeEntry) (int, []string, *agent.TokenUsage, error) {
var totalCount int
var allFiles []string
var totalTokens *agent.TokenUsage

for i := range sessionCount {
path := fmt.Sprintf("%s%d/%s", basePath, i, paths.MetadataFileName)
for summaryIndex, sessionPaths := range sessions {
sessionIndex, ok := sessionIndexFromFilePaths(basePath, sessionPaths)
if !ok {
return 0, nil, nil, fmt.Errorf("session %d metadata path %q is invalid", summaryIndex, sessionPaths.Metadata)
}
path := fmt.Sprintf("%s%d/%s", basePath, sessionIndex, paths.MetadataFileName)
entry, exists := entries[path]
if !exists {
return 0, nil, nil, fmt.Errorf("session %d metadata not found at %s", i, path)
return 0, nil, nil, fmt.Errorf("session %d metadata not found at %s", summaryIndex, path)
}
meta, err := s.readMetadataFromBlob(entry.Hash)
if err != nil {
return 0, nil, nil, fmt.Errorf("failed to read session %d metadata: %w", i, err)
return 0, nil, nil, fmt.Errorf("failed to read session %d metadata: %w", summaryIndex, err)
}
totalCount += meta.CheckpointsCount
allFiles = mergeFilesTouched(allFiles, meta.FilesTouched)
Expand All @@ -661,6 +702,57 @@ func (s *GitStore) reaggregateFromEntries(basePath string, sessionCount int, ent
return totalCount, allFiles, totalTokens, nil
}

func sessionFilePathsPosition(basePath string, sessions []SessionFilePaths, targetIndex int) int {
for i, sessionPaths := range sessions {
sessionIndex, ok := sessionIndexFromFilePaths(basePath, sessionPaths)
if ok && sessionIndex == targetIndex {
return i
}
}
return -1
}

func sortSessionFilePaths(basePath string, sessions []SessionFilePaths) {
sort.SliceStable(sessions, func(i, j int) bool {
left, leftOK := sessionIndexFromFilePaths(basePath, sessions[i])
right, rightOK := sessionIndexFromFilePaths(basePath, sessions[j])
if !leftOK || !rightOK {
return leftOK
}
return left < right
})
}

func sessionIndexFromFilePaths(basePath string, sessionPaths SessionFilePaths) (int, bool) {
if sessionPaths.Metadata == "" {
return 0, false
}
metadataPath := strings.TrimPrefix(sessionPaths.Metadata, "/")
relativePath, ok := strings.CutPrefix(metadataPath, basePath)
if !ok {
return 0, false
}
sessionDir, fileName, ok := strings.Cut(relativePath, "/")
if !ok || fileName != paths.MetadataFileName {
return 0, false
}
sessionIndex, err := strconv.Atoi(sessionDir)
if err != nil || sessionIndex < 0 {
return 0, false
}
return sessionIndex, true
}

func sessionPathHasEntries(basePath string, sessionIndex int, entries map[string]object.TreeEntry) bool {
prefix := fmt.Sprintf("%s%d/", basePath, sessionIndex)
for path := range entries {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}

func checkpointCreatedAt(opts WriteCommittedOptions) time.Time {
if opts.CreatedAt.IsZero() {
return time.Now().UTC()
Expand Down Expand Up @@ -1941,11 +2033,14 @@ func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) {
// CreateCommit creates a git commit object with the given tree, parent, message, and author.
// If parentHash is ZeroHash, the commit is created without a parent (orphan commit).
func CreateCommit(ctx context.Context, repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) {
now := time.Now()
return createCommitObject(ctx, repo, treeHash, parentHash, message, authorName, authorEmail, time.Now())
}

func createCommitObject(ctx context.Context, repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string, commitTime time.Time) (plumbing.Hash, error) {
sig := object.Signature{
Name: authorName,
Email: authorEmail,
When: now,
When: commitTime,
}

commit := &object.Commit{
Expand Down Expand Up @@ -1978,6 +2073,9 @@ func CreateCommit(ctx context.Context, repo *git.Repository, treeHash, parentHas
// If signing is disabled, no signer can be created, or signing fails, the commit
// is left unsigned and the error is logged.
func SignCommitBestEffort(ctx context.Context, commit *object.Commit) {
if commitSigningDisabled(ctx) {
return
}
if !settings.IsSignCheckpointCommitsEnabled(ctx) {
return
}
Expand Down
100 changes: 100 additions & 0 deletions cmd/entire/cli/checkpoint/committed_commit_time_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package checkpoint

import (
"context"
"testing"
"time"

"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/redact"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/object"
"github.com/stretchr/testify/require"
)

const (
commitTimeStrategy = "manual-commit"
commitTimeTestAuthor = "Test"
commitTimeTestEmail = "test@example.com"
)

func TestWriteCommitted_CommitTime(t *testing.T) {
t.Parallel()

repo, store := setupCommittedCommitTimeRepo(t)
commitTime := time.Date(2024, 3, 2, 1, 2, 3, 0, time.UTC)

err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: id.MustCheckpointID("a1b2c3d4e5f6"),
SessionID: "session-commit-time",
CreatedAt: time.Date(2024, 3, 1, 1, 2, 3, 0, time.UTC),
CommitTime: commitTime,
Strategy: commitTimeStrategy,
Transcript: redact.AlreadyRedacted([]byte("transcript line\n")),
AuthorName: "Migration",
AuthorEmail: "migration@example.com",
})
require.NoError(t, err)

commit := metadataHeadCommit(t, repo)
require.True(t, commit.Author.When.Equal(commitTime), "author time = %s, want %s", commit.Author.When, commitTime)
require.True(t, commit.Committer.When.Equal(commitTime), "committer time = %s, want %s", commit.Committer.When, commitTime)
}

func TestWriteCommitted_ZeroCommitTimeUsesCurrentTime(t *testing.T) {
t.Parallel()

repo, store := setupCommittedCommitTimeRepo(t)
createdAt := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
before := time.Now().Add(-time.Second)

err := store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: id.MustCheckpointID("b2c3d4e5f6a1"),
SessionID: "session-current-time",
CreatedAt: createdAt,
Strategy: commitTimeStrategy,
Transcript: redact.AlreadyRedacted([]byte("transcript line\n")),
AuthorName: commitTimeTestAuthor,
AuthorEmail: commitTimeTestEmail,
})
require.NoError(t, err)
after := time.Now().Add(time.Second)

commit := metadataHeadCommit(t, repo)
require.False(t, commit.Author.When.Equal(createdAt), "zero CommitTime should not reuse CreatedAt as the commit timestamp")
require.False(t, commit.Author.When.Before(before), "author time = %s, want no earlier than %s", commit.Author.When, before)
require.False(t, commit.Author.When.After(after), "author time = %s, want no later than %s", commit.Author.When, after)
require.True(t, commit.Committer.When.Equal(commit.Author.When), "committer time = %s, want author time %s", commit.Committer.When, commit.Author.When)
}

func setupCommittedCommitTimeRepo(t *testing.T) (*git.Repository, *GitStore) {
t.Helper()

dir := t.TempDir()
testutil.InitRepo(t, dir)

repo, err := git.PlainOpen(dir)
require.NoError(t, err)

testutil.WriteFile(t, dir, "README.md", "# Test\n")
testutil.GitAdd(t, dir, "README.md")
testutil.GitCommit(t, dir, "initial commit")

return repo, NewGitStore(repo)
}

func metadataHeadCommit(t *testing.T, repo *git.Repository) *object.Commit {
t.Helper()

ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
require.NoError(t, err)

commit, err := repo.CommitObject(ref.Hash())
require.NoError(t, err)

return commit
}
16 changes: 16 additions & 0 deletions cmd/entire/cli/checkpoint/committed_signing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ func TestSignCommitBestEffort_SkipsWhenDisabled(t *testing.T) { //nolint:paralle
}
}

func TestSignCommitBestEffort_SkipsWhenContextDisabled(t *testing.T) { //nolint:paralleltest // t.Chdir requires non-parallel
setupSigningEnv(t, false)

objectSignerLoader = func(context.Context) (plugin.Signer, bool) {
t.Fatal("signer should not be called when commit signing is disabled by context")
return nil, true
}

commit := newTestCommit()
SignCommitBestEffort(WithCommitSigningDisabled(context.Background()), commit)

if commit.Signature != "" {
t.Errorf("expected empty signature, got %q", commit.Signature)
}
}

func TestSignCommitBestEffort_ErrorIsBestEffort(t *testing.T) { //nolint:paralleltest // t.Chdir requires non-parallel
setupSigningEnv(t, false)

Expand Down
4 changes: 4 additions & 0 deletions cmd/entire/cli/checkpoint/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,10 @@ func (s *GitStore) createCommit(ctx context.Context, treeHash, parentHash plumbi
return CreateCommit(ctx, s.repo, treeHash, parentHash, message, authorName, authorEmail)
}

func (s *GitStore) createCommitAt(ctx context.Context, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string, commitTime time.Time) (plumbing.Hash, error) {
return createCommitObject(ctx, s.repo, treeHash, parentHash, message, authorName, authorEmail, commitTime)
}

// Helper functions extracted from strategy/common.go
// These are exported for use by strategy package (push_common.go, session_test.go)

Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/checkpoint/v2_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ func (s *V2GitStore) GetCheckpointAuthor(ctx context.Context, checkpointID id.Ch
}

// ReadSessionContent reads a session's metadata and prompts from the v2 /main ref,
// and the raw transcript (raw_transcript) from /full/current.
// and the raw transcript (raw_transcript) from local or remote /full refs.
// This is the v2 equivalent of GitStore.ReadSessionContent — it reads the raw agent
// transcript, not the compact transcript.jsonl. Used by resume and RestoreLogsOnly.
// Returns ErrNoTranscript if the session exists but no raw transcript is available.
Expand Down
Loading
Loading