diff --git a/cmd/entire/cli/checkpoint/checkpoint_test.go b/cmd/entire/cli/checkpoint/checkpoint_test.go index 13d5fbfc94..042796790a 100644 --- a/cmd/entire/cli/checkpoint/checkpoint_test.go +++ b/cmd/entire/cli/checkpoint/checkpoint_test.go @@ -545,7 +545,7 @@ func TestWriteCommitted_MergesVercelConfigOnMetadataBranch(t *testing.T) { } store := NewGitStore(repo) - commitHash, err := store.createCommit(treeHash, plumbing.ZeroHash, "Initialize metadata branch", "Test", "test@test.com") + commitHash, err := store.createCommit(context.Background(), treeHash, plumbing.ZeroHash, "Initialize metadata branch", "Test", "test@test.com") if err != nil { t.Fatalf("createCommit() error = %v", err) } diff --git a/cmd/entire/cli/checkpoint/committed.go b/cmd/entire/cli/checkpoint/committed.go index 98b83caa6b..16f9dfec26 100644 --- a/cmd/entire/cli/checkpoint/committed.go +++ b/cmd/entire/cli/checkpoint/committed.go @@ -23,6 +23,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/vercelconfig" @@ -36,6 +37,7 @@ import ( "github.com/go-git/go-git/v6/plumbing/filemode" "github.com/go-git/go-git/v6/plumbing/object" "github.com/go-git/go-git/v6/utils/binary" + "github.com/go-git/go-git/v6/x/plugin" ) // errStopIteration is used to stop commit iteration early in GetCheckpointAuthor. @@ -116,7 +118,7 @@ func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOption } commitMsg := s.buildCommitMessage(opts, taskMetadataPath) - newCommitHash, err := s.createCommit(newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) + newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) if err != nil { return err } @@ -427,8 +429,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, len(sessions), entries) if err != nil { return fmt.Errorf("failed to aggregate session stats: %w", err) } @@ -526,7 +527,7 @@ func (s *GitStore) UpdateCheckpointSummary(ctx context.Context, checkpointID id. authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Update checkpoint summary for %s", checkpointID) - newCommitHash, err := s.createCommit(newTreeHash, parentHash, commitMsg, authorName, authorEmail) + newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail) if err != nil { return err } @@ -1237,7 +1238,7 @@ func (s *GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoint authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", checkpointID, existingMetadata.SessionID) - newCommitHash, err := s.createCommit(newTreeHash, parentHash, commitMsg, authorName, authorEmail) + newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail) if err != nil { return err } @@ -1356,7 +1357,7 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Finalize transcript for Checkpoint: %s", opts.CheckpointID) - newCommitHash, err := s.createCommit(newTreeHash, parentHash, commitMsg, authorName, authorEmail) + newCommitHash, err := s.createCommit(ctx, newTreeHash, parentHash, commitMsg, authorName, authorEmail) if err != nil { return err } @@ -1521,7 +1522,7 @@ func (s *GitStore) ensureSessionsBranch(ctx context.Context) error { } authorName, authorEmail := GetGitAuthorFromRepo(s.repo) - commitHash, err := s.createCommit(emptyTreeHash, plumbing.ZeroHash, "Initialize sessions branch", authorName, authorEmail) + commitHash, err := s.createCommit(ctx, emptyTreeHash, plumbing.ZeroHash, "Initialize sessions branch", authorName, authorEmail) if err != nil { return err } @@ -1731,6 +1732,21 @@ func GetGitAuthorFromRepo(repo *git.Repository) (name, email string) { email = cfg.User.Email } + // If not found in local config, try global config + if name == "" || email == "" { + //nolint:staticcheck // the v6 is not yet released, revisit once it is. + globalCfg, err := config.LoadConfig(config.GlobalScope) + if err == nil { + if name == "" { + name = globalCfg.User.Name + } + if email == "" { + email = globalCfg.User.Email + } + } + } + + // Provide sensible defaults if git user is not configured if name == "" { name = "Unknown" } @@ -1743,7 +1759,7 @@ 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(repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { +func CreateCommit(ctx context.Context, repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { now := time.Now() sig := object.Signature{ Name: authorName, @@ -1762,6 +1778,8 @@ func CreateCommit(repo *git.Repository, treeHash, parentHash plumbing.Hash, mess commit.ParentHashes = []plumbing.Hash{parentHash} } + SignCommitBestEffort(ctx, commit) + obj := repo.Storer.NewEncodedObject() if err := commit.Encode(obj); err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) @@ -1775,6 +1793,51 @@ func CreateCommit(repo *git.Repository, treeHash, parentHash plumbing.Hash, mess return hash, nil } +// SignCommitBestEffort signs the commit using the registered ObjectSigner plugin. +// If no signer is registered, signing is disabled via settings, or signing fails, +// the commit is left unsigned and the error is logged. +func SignCommitBestEffort(ctx context.Context, commit *object.Commit) { + // Check plugin availability first (in-memory) before hitting disk for settings. + if !plugin.Has(plugin.ObjectSigner()) { + return + } + + if !settings.IsSignCheckpointCommitsEnabled(ctx) { + return + } + + signer, err := plugin.Get(plugin.ObjectSigner()) + if err != nil { + logging.Warn(ctx, "failed to get object signer", slog.String("error", err.Error())) + return + } + + if signer == nil { + return + } + + encoded := &plumbing.MemoryObject{} + if err = commit.EncodeWithoutSignature(encoded); err != nil { + logging.Warn(ctx, "failed to encode commit for signing", slog.String("error", err.Error())) + return + } + + r, err := encoded.Reader() + if err != nil { + logging.Warn(ctx, "failed to read encoded commit", slog.String("error", err.Error())) + return + } + defer r.Close() + + sig, err := signer.Sign(r) + if err != nil { + logging.Warn(ctx, "failed to sign commit", slog.String("error", err.Error())) + return + } + + commit.Signature = string(sig) +} + // readTranscriptFromTree reads a transcript from a git tree, handling both chunked and non-chunked formats. // It checks for chunk files first (.001, .002, etc.), then falls back to the base file. // The agentType is used for reassembling chunks in the correct format. diff --git a/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go b/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go index 0bcbff74ad..231d4ed372 100644 --- a/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go +++ b/cmd/entire/cli/checkpoint/committed_reader_resolve_test.go @@ -248,7 +248,7 @@ func corruptV2MainMetadata(t *testing.T, repo *git.Repository, cpID id.Checkpoin }) require.NoError(t, err) - commitHash, err := CreateCommit(repo, rootTreeHash, parentHash, + commitHash, err := CreateCommit(context.Background(), repo, rootTreeHash, parentHash, "corrupt metadata for test", "Test", "test@test.com") require.NoError(t, err) diff --git a/cmd/entire/cli/checkpoint/committed_signing_test.go b/cmd/entire/cli/checkpoint/committed_signing_test.go new file mode 100644 index 0000000000..2ce4874700 --- /dev/null +++ b/cmd/entire/cli/checkpoint/committed_signing_test.go @@ -0,0 +1,154 @@ +package checkpoint + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/paths" + + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/go-git/go-git/v6/x/plugin" +) + +type stubSigner struct { + sig []byte + err error +} + +func (s *stubSigner) Sign(_ io.Reader) ([]byte, error) { + return s.sig, s.err +} + +func setupSigningEnv(t *testing.T, disableSigning bool) { + t.Helper() + + dir := t.TempDir() + + // Minimal git repo so paths.WorktreeRoot resolves. + if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + t.Fatal(err) + } + + entireDir := filepath.Join(dir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatal(err) + } + + if disableSigning { + content := `{"sign_checkpoint_commits": false}` + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + paths.ClearWorktreeRootCache() + t.Chdir(dir) + t.Cleanup(func() { + resetPluginEntry("object-signer") + paths.ClearWorktreeRootCache() + }) +} + +func newTestCommit() *object.Commit { + sig := object.Signature{ + Name: "Test", + Email: "test@test.com", + When: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + return &object.Commit{ + TreeHash: plumbing.ZeroHash, + Author: sig, + Committer: sig, + Message: "test commit", + } +} + +func TestSignCommitBestEffort_Signs(t *testing.T) { //nolint:paralleltest // t.Chdir requires non-parallel + setupSigningEnv(t, false) + + err := plugin.Register(plugin.ObjectSigner(), func() plugin.Signer { + return &stubSigner{sig: []byte("FAKESIG")} + }) + if err != nil { + t.Fatal(err) + } + + commit := newTestCommit() + SignCommitBestEffort(context.Background(), commit) + + if commit.Signature != "FAKESIG" { + t.Errorf("expected signature %q, got %q", "FAKESIG", commit.Signature) + } +} + +func TestSignCommitBestEffort_SkipsWhenDisabled(t *testing.T) { //nolint:paralleltest // t.Chdir requires non-parallel + setupSigningEnv(t, true) + + err := plugin.Register(plugin.ObjectSigner(), func() plugin.Signer { + t.Fatal("signer should not be called when signing is disabled") + return nil + }) + if err != nil { + t.Fatal(err) + } + + commit := newTestCommit() + SignCommitBestEffort(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) + + err := plugin.Register(plugin.ObjectSigner(), func() plugin.Signer { + return &stubSigner{err: errors.New("signing failed")} + }) + if err != nil { + t.Fatal(err) + } + + commit := newTestCommit() + SignCommitBestEffort(context.Background(), commit) + + if commit.Signature != "" { + t.Errorf("expected empty signature after error, got %q", commit.Signature) + } +} + +func TestSignCommitBestEffort_NoSignerRegistered(t *testing.T) { //nolint:paralleltest // t.Chdir requires non-parallel + setupSigningEnv(t, false) + + commit := newTestCommit() + SignCommitBestEffort(context.Background(), commit) + + if commit.Signature != "" { + t.Errorf("expected empty signature without signer, got %q", commit.Signature) + } +} + +func TestSignCommitBestEffort_NilSigner(t *testing.T) { //nolint:paralleltest // t.Chdir requires non-parallel + setupSigningEnv(t, false) + + err := plugin.Register(plugin.ObjectSigner(), func() plugin.Signer { + return nil + }) + if err != nil { + t.Fatal(err) + } + + commit := newTestCommit() + SignCommitBestEffort(context.Background(), commit) + + if commit.Signature != "" { + t.Errorf("expected empty signature with nil signer, got %q", commit.Signature) + } +} diff --git a/cmd/entire/cli/checkpoint/global_test.go b/cmd/entire/cli/checkpoint/global_test.go index 497726f904..37b5fea6dc 100644 --- a/cmd/entire/cli/checkpoint/global_test.go +++ b/cmd/entire/cli/checkpoint/global_test.go @@ -15,7 +15,9 @@ func TestMain(m *testing.M) { // For tests, ensure that go-git always gets empty Configs for both // system and global scopes. This way the current environment does not // impact the tests. - err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { return config.NewEmpty() }) + err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { + return config.NewEmpty() + }) if err != nil { panic(fmt.Errorf("failed to register config storers: %w", err)) } diff --git a/cmd/entire/cli/checkpoint/temporary.go b/cmd/entire/cli/checkpoint/temporary.go index e7b45751a6..de151c054f 100644 --- a/cmd/entire/cli/checkpoint/temporary.go +++ b/cmd/entire/cli/checkpoint/temporary.go @@ -127,7 +127,7 @@ func (s *GitStore) WriteTemporary(ctx context.Context, opts WriteTemporaryOption // Create checkpoint commit with trailers commitMsg := trailers.FormatShadowCommit(opts.CommitMessage, opts.MetadataDir, opts.SessionID) - commitHash, err := s.createCommit(treeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) + commitHash, err := s.createCommit(ctx, treeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail) if err != nil { return WriteTemporaryResult{}, fmt.Errorf("failed to create commit: %w", err) } @@ -285,7 +285,7 @@ func (s *GitStore) WriteTemporaryTask(ctx context.Context, opts WriteTemporaryTa } // Create the commit - commitHash, err := s.createCommit(newTreeHash, parentHash, opts.CommitMessage, opts.AuthorName, opts.AuthorEmail) + commitHash, err := s.createCommit(ctx, newTreeHash, parentHash, opts.CommitMessage, opts.AuthorName, opts.AuthorEmail) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to create commit: %w", err) } @@ -790,8 +790,8 @@ func (s *GitStore) buildTreeWithChanges( } // createCommit creates a commit object. -func (s *GitStore) createCommit(treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { - return CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) +func (s *GitStore) createCommit(ctx context.Context, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { + return CreateCommit(ctx, s.repo, treeHash, parentHash, message, authorName, authorEmail) } // Helper functions extracted from strategy/common.go diff --git a/cmd/entire/cli/checkpoint/v2_committed.go b/cmd/entire/cli/checkpoint/v2_committed.go index bc824a7603..d4f940b9aa 100644 --- a/cmd/entire/cli/checkpoint/v2_committed.go +++ b/cmd/entire/cli/checkpoint/v2_committed.go @@ -179,7 +179,7 @@ func (s *V2GitStore) updateCommittedMain(ctx context.Context, opts UpdateCommitt authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Finalize checkpoint: %s\n", opts.CheckpointID) - if err := s.updateRef(refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail); err != nil { + if err := s.updateRef(ctx, refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail); err != nil { return 0, err } @@ -270,7 +270,7 @@ func (s *V2GitStore) updateCommittedFullTranscript(ctx context.Context, opts Upd authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Finalize checkpoint: %s\n", opts.CheckpointID) - return s.updateRef(refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail) + return s.updateRef(ctx, refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail) } // writeCommittedMain writes metadata entries to the /main ref. @@ -310,7 +310,7 @@ func (s *V2GitStore) writeCommittedMain(ctx context.Context, opts WriteCommitted } commitMsg := fmt.Sprintf("Checkpoint: %s\n", opts.CheckpointID) - if err := s.updateRef(refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail); err != nil { + if err := s.updateRef(ctx, refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail); err != nil { return 0, err } return sessionIndex, nil @@ -536,7 +536,7 @@ func (s *V2GitStore) writeCommittedFullTranscript(ctx context.Context, opts Writ } commitMsg := fmt.Sprintf("Checkpoint: %s\n", opts.CheckpointID) - if err := s.updateRef(refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail); err != nil { + if err := s.updateRef(ctx, refName, newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail); err != nil { return err } @@ -703,5 +703,5 @@ func (s *V2GitStore) UpdateSummary(ctx context.Context, checkpointID id.Checkpoi authorName, authorEmail := GetGitAuthorFromRepo(s.repo) commitMsg := fmt.Sprintf("Update summary for checkpoint %s (session: %s)", checkpointID, metadata.SessionID) - return s.updateRef(refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail) + return s.updateRef(ctx, refName, newTreeHash, parentHash, commitMsg, authorName, authorEmail) } diff --git a/cmd/entire/cli/checkpoint/v2_generation.go b/cmd/entire/cli/checkpoint/v2_generation.go index 7f088c1c8b..013be496ec 100644 --- a/cmd/entire/cli/checkpoint/v2_generation.go +++ b/cmd/entire/cli/checkpoint/v2_generation.go @@ -312,7 +312,7 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error { } authorName, authorEmail := GetGitAuthorFromRepo(s.repo) - archiveCommitHash, err := CreateCommit(s.repo, archiveTreeHash, currentRef.Hash(), "Archive generation", authorName, authorEmail) + archiveCommitHash, err := CreateCommit(ctx, s.repo, archiveTreeHash, currentRef.Hash(), "Archive generation", authorName, authorEmail) if err != nil { return fmt.Errorf("rotation: failed to create archive commit: %w", err) } @@ -329,7 +329,7 @@ func (s *V2GitStore) rotateGeneration(ctx context.Context) error { return fmt.Errorf("rotation: failed to build empty tree: %w", err) } - orphanCommitHash, err := CreateCommit(s.repo, emptyTreeHash, plumbing.ZeroHash, "Start generation", authorName, authorEmail) + orphanCommitHash, err := CreateCommit(ctx, s.repo, emptyTreeHash, plumbing.ZeroHash, "Start generation", authorName, authorEmail) if err != nil { return fmt.Errorf("rotation: failed to create orphan commit: %w", err) } diff --git a/cmd/entire/cli/checkpoint/v2_generation_test.go b/cmd/entire/cli/checkpoint/v2_generation_test.go index f7a699aaab..6ec7f2332f 100644 --- a/cmd/entire/cli/checkpoint/v2_generation_test.go +++ b/cmd/entire/cli/checkpoint/v2_generation_test.go @@ -108,7 +108,7 @@ func TestReadGenerationFromRef(t *testing.T) { refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) authorName, authorEmail := GetGitAuthorFromRepo(repo) - commitHash, err := CreateCommit(repo, treeHash, plumbing.ZeroHash, "test", authorName, authorEmail) + commitHash, err := CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "test", authorName, authorEmail) require.NoError(t, err) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash))) @@ -297,7 +297,7 @@ func createArchivedRef(t *testing.T, repo *git.Repository, number int) { require.NoError(t, err) authorName, authorEmail := GetGitAuthorFromRepo(repo) - commitHash, err := CreateCommit(repo, treeHash, plumbing.ZeroHash, "archived", authorName, authorEmail) + commitHash, err := CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "archived", authorName, authorEmail) require.NoError(t, err) refName := plumbing.ReferenceName(fmt.Sprintf("%s%013d", paths.V2FullRefPrefix, number)) diff --git a/cmd/entire/cli/checkpoint/v2_read_test.go b/cmd/entire/cli/checkpoint/v2_read_test.go index 771e830017..9e740412cf 100644 --- a/cmd/entire/cli/checkpoint/v2_read_test.go +++ b/cmd/entire/cli/checkpoint/v2_read_test.go @@ -232,7 +232,7 @@ func TestV2ReadSessionContent_ChunkedTranscript(t *testing.T) { parentHash, _, err := v2Store.GetRefState(refName) require.NoError(t, err) - err = v2Store.updateRef(refName, newTreeHash, parentHash, "chunked test", "Test", "test@test.com") + err = v2Store.updateRef(ctx, refName, newTreeHash, parentHash, "chunked test", "Test", "test@test.com") require.NoError(t, err) // Read it back — should reassemble both chunks diff --git a/cmd/entire/cli/checkpoint/v2_store.go b/cmd/entire/cli/checkpoint/v2_store.go index 059080262c..fd28b1fcca 100644 --- a/cmd/entire/cli/checkpoint/v2_store.go +++ b/cmd/entire/cli/checkpoint/v2_store.go @@ -68,7 +68,7 @@ func (s *V2GitStore) ensureRef(ctx context.Context, refName plumbing.ReferenceNa } authorName, authorEmail := GetGitAuthorFromRepo(s.repo) - commitHash, err := CreateCommit(s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize v2 ref", authorName, authorEmail) + commitHash, err := CreateCommit(ctx, s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize v2 ref", authorName, authorEmail) if err != nil { return fmt.Errorf("failed to create initial commit: %w", err) } @@ -97,8 +97,8 @@ func (s *V2GitStore) GetRefState(refName plumbing.ReferenceName) (parentHash, tr } // updateRef creates a new commit on a ref with the given tree, updating the ref to point to it. -func (s *V2GitStore) updateRef(refName plumbing.ReferenceName, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) error { - commitHash, err := CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) +func (s *V2GitStore) updateRef(ctx context.Context, refName plumbing.ReferenceName, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) error { + commitHash, err := CreateCommit(ctx, s.repo, treeHash, parentHash, message, authorName, authorEmail) if err != nil { return fmt.Errorf("failed to create commit: %w", err) } diff --git a/cmd/entire/cli/checkpoint/v2_store_test.go b/cmd/entire/cli/checkpoint/v2_store_test.go index f3bf5a1187..22319d3472 100644 --- a/cmd/entire/cli/checkpoint/v2_store_test.go +++ b/cmd/entire/cli/checkpoint/v2_store_test.go @@ -155,7 +155,7 @@ func TestV2GitStore_UpdateRef_CreatesCommit(t *testing.T) { require.NotEqual(t, treeHash, newTreeHash) // Update the ref - require.NoError(t, store.updateRef(refName, newTreeHash, parentHash, "test commit", "Test", "test@test.com")) + require.NoError(t, store.updateRef(context.Background(), refName, newTreeHash, parentHash, "test commit", "Test", "test@test.com")) // Verify the ref now points to a commit with our tree ref, err := repo.Reference(refName, true) @@ -908,7 +908,7 @@ func TestV2GitStore_UpdateCommitted_PreservesExistingTaskMetadataInFullCurrent(t require.NoError(t, err) authorName, authorEmail := GetGitAuthorFromRepo(repo) - commitHash, err := CreateCommit(repo, newRootHash, parentHash, + commitHash, err := CreateCommit(ctx, repo, newRootHash, parentHash, fmt.Sprintf("Checkpoint: %s (task metadata)\n", cpID), authorName, authorEmail) require.NoError(t, err) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash))) diff --git a/cmd/entire/cli/clean_test.go b/cmd/entire/cli/clean_test.go index dda6945a78..f0b212eab7 100644 --- a/cmd/entire/cli/clean_test.go +++ b/cmd/entire/cli/clean_test.go @@ -168,7 +168,7 @@ func createCleanV2Ref(t *testing.T, repo *git.Repository, refName plumbing.Refer t.Fatalf("failed to build empty tree for %s: %v", refName, err) } - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "init v2 ref", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "init v2 ref", "test", "test@test.com") if err != nil { t.Fatalf("failed to create commit for %s: %v", refName, err) } @@ -220,7 +220,7 @@ func createArchivedGenerationRef(t *testing.T, repo *git.Repository, generation t.Fatalf("failed to build archived generation tree: %v", err) } - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "archived generation", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "archived generation", "test", "test@test.com") if err != nil { t.Fatalf("failed to create archived generation commit: %v", err) } @@ -253,7 +253,7 @@ func createArchivedGenerationRefWithoutMetadata(t *testing.T, repo *git.Reposito t.Fatalf("failed to build archived generation tree: %v", err) } - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "archived generation without metadata", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "archived generation without metadata", "test", "test@test.com") if err != nil { t.Fatalf("failed to create archived generation commit: %v", err) } diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index 67aeab9615..c7795a7697 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -33,7 +33,7 @@ func createV2Ref(t *testing.T, repo *git.Repository, refName string) { treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, make(map[string]object.TreeEntry)) require.NoError(t, err) - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "init v2 ref", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "init v2 ref", "test", "test@test.com") require.NoError(t, err) ref := plumbing.NewHashReference(plumbing.ReferenceName(refName), commitHash) @@ -75,7 +75,7 @@ func createV2RefWithCheckpoints(t *testing.T, repo *git.Repository, refName stri treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "v2 ref with checkpoints", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "v2 ref with checkpoints", "test", "test@test.com") require.NoError(t, err) ref := plumbing.NewHashReference(plumbing.ReferenceName(refName), commitHash) @@ -527,7 +527,7 @@ func createArchivedGeneration(t *testing.T, repo *git.Repository, generationNum treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "archived generation", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "archived generation", "test", "test@test.com") require.NoError(t, err) refName := fmt.Sprintf("%s%013d", paths.V2FullRefPrefix, generationNum) diff --git a/cmd/entire/cli/global_test.go b/cmd/entire/cli/global_test.go index 6a2442b491..42a4a434e7 100644 --- a/cmd/entire/cli/global_test.go +++ b/cmd/entire/cli/global_test.go @@ -15,7 +15,9 @@ func TestMain(m *testing.M) { // Register a default ConfigSource so tests that call ConfigScoped // (directly or indirectly via Commit/CreateTag) don't fail with // "no config loader registered". - err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { return config.NewEmpty() }) + err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { + return config.NewEmpty() + }) if err != nil { panic(fmt.Errorf("failed to register config storers: %w", err)) } diff --git a/cmd/entire/cli/integration_test/explain_test.go b/cmd/entire/cli/integration_test/explain_test.go index c1b788c60f..93ef7fb18d 100644 --- a/cmd/entire/cli/integration_test/explain_test.go +++ b/cmd/entire/cli/integration_test/explain_test.go @@ -413,7 +413,7 @@ func corruptV2MainRef(t *testing.T, repo *git.Repository, checkpointID string) { rootTreeHash, err := repo.Storer.SetEncodedObject(rootTreeObj) require.NoError(t, err) - commitHash, err := checkpoint.CreateCommit(repo, rootTreeHash, parentHash, + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, rootTreeHash, parentHash, "corrupt metadata for test", "Test", "test@test.com") require.NoError(t, err) diff --git a/cmd/entire/cli/migrate.go b/cmd/entire/cli/migrate.go index dccf800ac6..1c8ae214c1 100644 --- a/cmd/entire/cli/migrate.go +++ b/cmd/entire/cli/migrate.go @@ -223,7 +223,7 @@ func migrateOneCheckpoint(ctx context.Context, repo *git.Repository, v1Store *ch // Copy task metadata trees from v1 to v2 /full/current if shouldCopyTaskMetadata { - if taskErr := copyTaskMetadataToV2(repo, v1Store, v2Store, info.CheckpointID, summary); taskErr != nil { + if taskErr := copyTaskMetadataToV2(ctx, repo, v1Store, v2Store, info.CheckpointID, summary); taskErr != nil { logging.Warn(ctx, "failed to copy task metadata to v2", slog.String("checkpoint_id", string(info.CheckpointID)), slog.String("error", taskErr.Error()), @@ -511,7 +511,7 @@ func computeCompactOffset(ctx context.Context, fullTranscript, fullCompact []byt // copyTaskMetadataToV2 copies task metadata files (subagent transcripts, checkpoint JSONs) // from the v1 branch to the v2 /full/current ref via tree surgery. -func copyTaskMetadataToV2(repo *git.Repository, _ *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, summary *checkpoint.CheckpointSummary) error { +func copyTaskMetadataToV2(ctx context.Context, repo *git.Repository, _ *checkpoint.GitStore, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, summary *checkpoint.CheckpointSummary) error { // Resolve the v1 branch tree v1Tree, err := resolveV1CheckpointTree(repo, cpID) if err != nil { @@ -523,7 +523,7 @@ func copyTaskMetadataToV2(repo *git.Repository, _ *checkpoint.GitStore, v2Store if rootTasksTree, rootTasksErr := v1Tree.Tree("tasks"); rootTasksErr == nil { if len(summary.Sessions) > 0 { latestSessionIdx := len(summary.Sessions) - 1 - if spliceErr := spliceTasksTreeToV2(repo, v2Store, cpID, latestSessionIdx, rootTasksTree.Hash); spliceErr != nil { + if spliceErr := spliceTasksTreeToV2(ctx, repo, v2Store, cpID, latestSessionIdx, rootTasksTree.Hash); spliceErr != nil { return fmt.Errorf("latest session task tree splice failed: %w", spliceErr) } } @@ -541,7 +541,7 @@ func copyTaskMetadataToV2(repo *git.Repository, _ *checkpoint.GitStore, v2Store continue // No tasks directory in this session } - if spliceErr := spliceTasksTreeToV2(repo, v2Store, cpID, sessionIdx, tasksTree.Hash); spliceErr != nil { + if spliceErr := spliceTasksTreeToV2(ctx, repo, v2Store, cpID, sessionIdx, tasksTree.Hash); spliceErr != nil { return fmt.Errorf("session %d task tree splice failed: %w", sessionIdx, spliceErr) } } @@ -580,7 +580,7 @@ func resolveV1CheckpointTree(repo *git.Repository, cpID id.CheckpointID) (*objec return cpTree, nil } -func spliceTasksTreeToV2(repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int, tasksTreeHash plumbing.Hash) error { +func spliceTasksTreeToV2(ctx context.Context, repo *git.Repository, v2Store *checkpoint.V2GitStore, cpID id.CheckpointID, sessionIdx int, tasksTreeHash plumbing.Hash) error { refName := plumbing.ReferenceName(paths.V2FullCurrentRefName) parentHash, rootTreeHash, err := v2Store.GetRefState(refName) if err != nil { @@ -604,7 +604,7 @@ func spliceTasksTreeToV2(repo *git.Repository, v2Store *checkpoint.V2GitStore, c return fmt.Errorf("tree surgery failed: %w", err) } - commitHash, err := checkpoint.CreateCommit(repo, newRoot, parentHash, + commitHash, err := checkpoint.CreateCommit(ctx, repo, newRoot, parentHash, fmt.Sprintf("Add task metadata for %s\n", cpID), "Entire Migration", "migration@entire.dev") if err != nil { diff --git a/cmd/entire/cli/migrate_test.go b/cmd/entire/cli/migrate_test.go index 23adaefa13..82167e5dce 100644 --- a/cmd/entire/cli/migrate_test.go +++ b/cmd/entire/cli/migrate_test.go @@ -578,7 +578,7 @@ func removeV2SessionTranscriptFiles(t *testing.T, repo *git.Repository, v2Store ) require.NoError(t, updateErr) - commitHash, commitErr := checkpoint.CreateCommit(repo, newRootHash, parentHash, "test: remove full transcript\n", "Test", "test@test.com") + commitHash, commitErr := checkpoint.CreateCommit(context.Background(), repo, newRootHash, parentHash, "test: remove full transcript\n", "Test", "test@test.com") require.NoError(t, commitErr) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(refName, commitHash))) } @@ -628,8 +628,8 @@ func TestSpliceTasksTreeToV2_MergesTaskDirectories(t *testing.T) { rootTasksHash := buildTasksTreeHash(t, repo, "toolu_root") sessionTasksHash := buildTasksTreeHash(t, repo, "toolu_session") - require.NoError(t, spliceTasksTreeToV2(repo, v2Store, cpID, 0, rootTasksHash)) - require.NoError(t, spliceTasksTreeToV2(repo, v2Store, cpID, 0, sessionTasksHash)) + require.NoError(t, spliceTasksTreeToV2(context.Background(), repo, v2Store, cpID, 0, rootTasksHash)) + require.NoError(t, spliceTasksTreeToV2(context.Background(), repo, v2Store, cpID, 0, sessionTasksHash)) _, rootTreeHash, refErr := v2Store.GetRefState(plumbing.ReferenceName(paths.V2FullCurrentRefName)) require.NoError(t, refErr) diff --git a/cmd/entire/cli/objectsigner.go b/cmd/entire/cli/objectsigner.go new file mode 100644 index 0000000000..2a098339ed --- /dev/null +++ b/cmd/entire/cli/objectsigner.go @@ -0,0 +1,93 @@ +package cli + +import ( + "context" + "fmt" + "net" + "os" + "sync" + + "github.com/go-git/go-git/v6/config" + "github.com/go-git/go-git/v6/x/plugin" + "github.com/go-git/x/plugin/objectsigner/auto" + "golang.org/x/crypto/ssh/agent" +) + +var registerObjectSignerOnce sync.Once + +func RegisterObjectSigner() { + registerObjectSignerOnce.Do(func() { + //nolint:errcheck,gosec // best-effort; if registration fails, commits are left unsigned + plugin.Register(plugin.ObjectSigner(), func() plugin.Signer { + cfgSource, err := plugin.Get(plugin.ConfigLoader()) + if err != nil { + // No config loader registered; signing not possible. + return nil + } + + sysCfg := loadScopedConfig(cfgSource, config.SystemScope) + globalCfg := loadScopedConfig(cfgSource, config.GlobalScope) + + // Merge system then global so that global settings take precedence. + merged := config.Merge(sysCfg, globalCfg) + + if !merged.Commit.GpgSign.IsTrue() { + return nil + } + + cfg := auto.Config{ + SigningKey: merged.User.SigningKey, + Format: auto.Format(merged.GPG.Format), + SSHAgent: connectSSHAgent(), + } + + signer, err := auto.FromConfig(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to create object signer: %v\n", err) + return nil + } + + return signer + }) + }) +} + +// connectSSHAgent connects to the SSH agent via SSH_AUTH_SOCK. +// Returns nil if the agent is unavailable. +func connectSSHAgent() agent.Agent { //nolint:ireturn // must return the ssh agent interface + sock := os.Getenv("SSH_AUTH_SOCK") + if sock == "" { + return nil + } + + var d net.Dialer + conn, err := d.DialContext(context.Background(), "unix", sock) + if err != nil { + return nil + } + + return agent.NewClient(conn) +} + +var scopeName = map[config.Scope]string{ + config.GlobalScope: "global", + config.SystemScope: "system", +} + +func loadScopedConfig(source plugin.ConfigSource, scope config.Scope) *config.Config { + name := scopeName[scope] + + storer, err := source.Load(scope) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to load %s git config: %v\n", name, err) + return config.NewConfig() + } + + cfg, err := storer.Config() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to parse %s git config: %v\n", name, err) + return config.NewConfig() + } + + return cfg +} diff --git a/cmd/entire/cli/root_test.go b/cmd/entire/cli/root_test.go index 20734a3e48..71c8216549 100644 --- a/cmd/entire/cli/root_test.go +++ b/cmd/entire/cli/root_test.go @@ -4,9 +4,11 @@ import ( "bytes" "runtime" "strings" + "sync" "testing" "github.com/entireio/cli/cmd/entire/cli/versioninfo" + "github.com/go-git/go-git/v6/x/plugin" "github.com/spf13/cobra" ) @@ -68,6 +70,21 @@ func TestVersionFlag_ContainsExpectedInfo(t *testing.T) { } } +func TestRegisterObjectSigner_RegistersPlugin(t *testing.T) { + resetPluginEntry("object-signer") + registerObjectSignerOnce = sync.Once{} + t.Cleanup(func() { + resetPluginEntry("object-signer") + registerObjectSignerOnce = sync.Once{} + }) + + RegisterObjectSigner() + + if !plugin.Has(plugin.ObjectSigner()) { + t.Fatal("expected object signer plugin to be registered") + } +} + func TestPersistentPostRun_SkipsHiddenParent(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 53ab5810f7..4b7b3c19fe 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -37,7 +37,6 @@ const ( // EntireSettings represents the .entire/settings.json configuration type EntireSettings struct { - // Enabled indicates whether Entire is active. When false, CLI commands // show a disabled message and hooks exit silently. Defaults to true. Enabled bool `json:"enabled"` @@ -91,6 +90,10 @@ type EntireSettings struct { // that wires it into the deadline selection. SummaryTimeoutSeconds int `json:"summary_timeout_seconds,omitempty"` + // SignCheckpointCommits controls whether checkpoint commits are signed. + // nil/true = sign (default), false = skip signing. + SignCheckpointCommits *bool `json:"sign_checkpoint_commits,omitempty"` + // Deprecated: no longer used. Exists to tolerate old settings files // that still contain "strategy": "auto-commit" or similar. Strategy string `json:"strategy,omitempty"` @@ -280,7 +283,7 @@ func loadFromFile(filePath string) (*EntireSettings, error) { // mergeJSON merges JSON data into existing settings. // Only non-zero values from the JSON override existing settings. func mergeJSON(settings *EntireSettings, data []byte) error { - // First, validate that there are no unknown keys using strict decoding + // Validate that there are no unknown keys using strict decoding. dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() var temp EntireSettings @@ -288,166 +291,200 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return fmt.Errorf("parsing JSON: %w", err) } - // Parse into a map to check which fields are present + // Parse into a map to check which fields are present. var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return fmt.Errorf("parsing JSON: %w", err) } - // Override enabled if present - if enabledRaw, ok := raw["enabled"]; ok { - var e bool - if err := json.Unmarshal(enabledRaw, &e); err != nil { - return fmt.Errorf("parsing enabled field: %w", err) - } - settings.Enabled = e + if err := mergeScalarFields(settings, raw); err != nil { + return err } - - // Override local_dev if present - if localDevRaw, ok := raw["local_dev"]; ok { - var ld bool - if err := json.Unmarshal(localDevRaw, &ld); err != nil { - return fmt.Errorf("parsing local_dev field: %w", err) - } - settings.LocalDev = ld + if err := mergeStrategyOptions(settings, raw); err != nil { + return err } - - // Override absolute_git_hook_path if present - if ahpRaw, ok := raw["absolute_git_hook_path"]; ok { - var ahp bool - if err := json.Unmarshal(ahpRaw, &ahp); err != nil { - return fmt.Errorf("parsing absolute_git_hook_path field: %w", err) - } - settings.AbsoluteGitHookPath = ahp + if err := mergeSummaryGeneration(settings, raw); err != nil { + return err } - - // Override log_level if present and non-empty - if logLevelRaw, ok := raw["log_level"]; ok { - var ll string - if err := json.Unmarshal(logLevelRaw, &ll); err != nil { - return fmt.Errorf("parsing log_level field: %w", err) - } - if ll != "" { - settings.LogLevel = ll - } + if err := mergeCommitLinking(settings, raw); err != nil { + return err } - // Merge strategy_options if present - if optionsRaw, ok := raw["strategy_options"]; ok { - var opts map[string]any - if err := json.Unmarshal(optionsRaw, &opts); err != nil { - return fmt.Errorf("parsing strategy_options field: %w", err) + // Merge redaction sub-fields if present (field-level, not wholesale replace). + if redactionRaw, ok := raw["redaction"]; ok { + if settings.Redaction == nil { + settings.Redaction = &RedactionSettings{} } - if settings.StrategyOptions == nil { - settings.StrategyOptions = opts - } else { - for k, v := range opts { - settings.StrategyOptions[k] = v - } + if err := mergeRedaction(settings.Redaction, redactionRaw); err != nil { + return fmt.Errorf("parsing redaction field: %w", err) } } - // Override telemetry if present - if telemetryRaw, ok := raw["telemetry"]; ok { - var t bool - if err := json.Unmarshal(telemetryRaw, &t); err != nil { - return fmt.Errorf("parsing telemetry field: %w", err) - } - settings.Telemetry = &t + return nil +} + +// mergeScalarFields merges simple bool, *bool, string, and int fields from raw JSON. +func mergeScalarFields(settings *EntireSettings, raw map[string]json.RawMessage) error { + if err := mergeRawBool(raw, "enabled", &settings.Enabled); err != nil { + return err + } + if err := mergeRawBool(raw, "local_dev", &settings.LocalDev); err != nil { + return err + } + if err := mergeRawBool(raw, "absolute_git_hook_path", &settings.AbsoluteGitHookPath); err != nil { + return err + } + if err := mergeRawBool(raw, "external_agents", &settings.ExternalAgents); err != nil { + return err + } + if err := mergeRawBool(raw, "vercel", &settings.Vercel); err != nil { + return err } + if err := mergeRawBoolPtr(raw, "telemetry", &settings.Telemetry); err != nil { + return err + } + if err := mergeRawBoolPtr(raw, "sign_checkpoint_commits", &settings.SignCheckpointCommits); err != nil { + return err + } + if err := mergeRawStringNonEmpty(raw, "log_level", &settings.LogLevel); err != nil { + return err + } + if err := mergeRawInt(raw, "summary_timeout_seconds", &settings.SummaryTimeoutSeconds); err != nil { + return err + } + return nil +} - // Merge summary_generation sub-fields if present. - if summaryRaw, ok := raw["summary_generation"]; ok { - if settings.SummaryGeneration == nil { - settings.SummaryGeneration = &SummaryGenerationSettings{} - } +func mergeRawBool(raw map[string]json.RawMessage, key string, dst *bool) error { + v, ok := raw[key] + if !ok { + return nil + } + return unmarshalField(key, v, dst) +} - var summaryFields map[string]json.RawMessage - if err := json.Unmarshal(summaryRaw, &summaryFields); err != nil { - return fmt.Errorf("parsing summary_generation field: %w", err) - } +func mergeRawBoolPtr(raw map[string]json.RawMessage, key string, dst **bool) error { + v, ok := raw[key] + if !ok { + return nil + } + var b bool + if err := unmarshalField(key, v, &b); err != nil { + return err + } + *dst = &b + return nil +} - _, modelInOverride := summaryFields["model"] +func mergeRawStringNonEmpty(raw map[string]json.RawMessage, key string, dst *string) error { + v, ok := raw[key] + if !ok { + return nil + } + var s string + if err := unmarshalField(key, v, &s); err != nil { + return err + } + if s != "" { + *dst = s + } + return nil +} - if providerRaw, ok := summaryFields["provider"]; ok { - var provider string - if err := json.Unmarshal(providerRaw, &provider); err != nil { - return fmt.Errorf("parsing summary_generation.provider field: %w", err) - } - // If the override switches providers without also setting a - // model, the base's model was tuned to the old provider and - // would likely cause a runtime failure when handed to the new - // one (e.g. codex rejecting "sonnet"). Clear it so the new - // provider falls back to its own default. The configure CLI - // path enforces the same rule — keep the merge path consistent. - if provider != settings.SummaryGeneration.Provider && !modelInOverride { - settings.SummaryGeneration.Model = "" - } - settings.SummaryGeneration.Provider = provider - } +func mergeRawInt(raw map[string]json.RawMessage, key string, dst *int) error { + v, ok := raw[key] + if !ok { + return nil + } + return unmarshalField(key, v, dst) +} - if modelRaw, ok := summaryFields["model"]; ok { - var model string - if err := json.Unmarshal(modelRaw, &model); err != nil { - return fmt.Errorf("parsing summary_generation.model field: %w", err) - } - settings.SummaryGeneration.Model = model - } +func unmarshalField(key string, data json.RawMessage, dst any) error { + if err := json.Unmarshal(data, dst); err != nil { + return fmt.Errorf("parsing %s field: %w", key, err) } + return nil +} - // Merge redaction sub-fields if present (field-level, not wholesale replace). - if redactionRaw, ok := raw["redaction"]; ok { - if settings.Redaction == nil { - settings.Redaction = &RedactionSettings{} - } - if err := mergeRedaction(settings.Redaction, redactionRaw); err != nil { - return fmt.Errorf("parsing redaction field: %w", err) +func mergeStrategyOptions(settings *EntireSettings, raw map[string]json.RawMessage) error { + optionsRaw, ok := raw["strategy_options"] + if !ok { + return nil + } + var opts map[string]any + if err := unmarshalField("strategy_options", optionsRaw, &opts); err != nil { + return err + } + if settings.StrategyOptions == nil { + settings.StrategyOptions = opts + } else { + for k, v := range opts { + settings.StrategyOptions[k] = v } } + return nil +} - // Override commit_linking if present and non-empty - if commitLinkingRaw, ok := raw["commit_linking"]; ok { - var cl string - if err := json.Unmarshal(commitLinkingRaw, &cl); err != nil { - return fmt.Errorf("parsing commit_linking field: %w", err) - } - if cl != "" { - switch cl { - case CommitLinkingAlways, CommitLinkingPrompt: - settings.CommitLinking = cl - default: - return fmt.Errorf("invalid commit_linking value %q: must be %q or %q", cl, CommitLinkingAlways, CommitLinkingPrompt) - } - } +func mergeSummaryGeneration(settings *EntireSettings, raw map[string]json.RawMessage) error { + summaryRaw, ok := raw["summary_generation"] + if !ok { + return nil + } + if settings.SummaryGeneration == nil { + settings.SummaryGeneration = &SummaryGenerationSettings{} } - // Override external_agents if present - if externalAgentsRaw, ok := raw["external_agents"]; ok { - var ea bool - if err := json.Unmarshal(externalAgentsRaw, &ea); err != nil { - return fmt.Errorf("parsing external_agents field: %w", err) - } - settings.ExternalAgents = ea + var summaryFields map[string]json.RawMessage + if err := unmarshalField("summary_generation", summaryRaw, &summaryFields); err != nil { + return err } - // Override vercel if present - if vercelRaw, ok := raw["vercel"]; ok { - var vercel bool - if err := json.Unmarshal(vercelRaw, &vercel); err != nil { - return fmt.Errorf("parsing vercel field: %w", err) + _, modelInOverride := summaryFields["model"] + + if providerRaw, ok := summaryFields["provider"]; ok { + var provider string + if err := unmarshalField("summary_generation.provider", providerRaw, &provider); err != nil { + return err } - settings.Vercel = vercel + // If the override switches providers without also setting a model, + // the base's model was tuned to the old provider and would likely + // cause a runtime failure when handed to the new one (e.g. codex + // rejecting "sonnet"). Clear it so the new provider falls back to + // its own default. + if provider != settings.SummaryGeneration.Provider && !modelInOverride { + settings.SummaryGeneration.Model = "" + } + settings.SummaryGeneration.Provider = provider } - // Override summary_timeout_seconds if present - if summaryTimeoutRaw, ok := raw["summary_timeout_seconds"]; ok { - var st int - if err := json.Unmarshal(summaryTimeoutRaw, &st); err != nil { - return fmt.Errorf("parsing summary_timeout_seconds field: %w", err) + if modelRaw, ok := summaryFields["model"]; ok { + var model string + if err := unmarshalField("summary_generation.model", modelRaw, &model); err != nil { + return err } - settings.SummaryTimeoutSeconds = st + settings.SummaryGeneration.Model = model } + return nil +} +func mergeCommitLinking(settings *EntireSettings, raw map[string]json.RawMessage) error { + commitLinkingRaw, ok := raw["commit_linking"] + if !ok { + return nil + } + var cl string + if err := unmarshalField("commit_linking", commitLinkingRaw, &cl); err != nil { + return err + } + if cl == "" { + return nil + } + switch cl { + case CommitLinkingAlways, CommitLinkingPrompt: + settings.CommitLinking = cl + default: + return fmt.Errorf("invalid commit_linking value %q: must be %q or %q", cl, CommitLinkingAlways, CommitLinkingPrompt) + } return nil } @@ -775,6 +812,22 @@ func IsExternalAgentsEnabled(ctx context.Context) bool { return s.ExternalAgents } +// IsSignCheckpointCommitsEnabled returns true if checkpoint commits should be signed. +// Defaults to true when the setting is not explicitly set. +func (s *EntireSettings) IsSignCheckpointCommitsEnabled() bool { + return s.SignCheckpointCommits == nil || *s.SignCheckpointCommits +} + +// IsSignCheckpointCommitsEnabled checks if checkpoint commit signing is enabled in settings. +// Returns true by default if settings cannot be loaded or the key is missing. +func IsSignCheckpointCommitsEnabled(ctx context.Context) bool { + s, err := Load(ctx) + if err != nil { + return true + } + return s.IsSignCheckpointCommitsEnabled() +} + // Save saves the settings to .entire/settings.json. func Save(ctx context.Context, settings *EntireSettings) error { return saveToFile(ctx, settings, EntireSettingsFile) diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index f9b63ae732..06c73c43fc 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -96,7 +96,8 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { "telemetry": true, "redaction": {"pii": {"enabled": true, "email": true, "phone": false}}, "external_agents": true, - "vercel": true + "vercel": true, + "sign_checkpoint_commits": false }` if err := os.WriteFile(settingsFile, []byte(settingsContent), 0644); err != nil { t.Fatalf("failed to write settings file: %v", err) @@ -153,6 +154,9 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { if !settings.Vercel { t.Error("expected vercel to be true") } + if settings.SignCheckpointCommits == nil || *settings.SignCheckpointCommits { + t.Error("expected sign_checkpoint_commits to be false") + } } func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) { diff --git a/cmd/entire/cli/strategy/clean_test.go b/cmd/entire/cli/strategy/clean_test.go index 440e27b304..2fff319fbe 100644 --- a/cmd/entire/cli/strategy/clean_test.go +++ b/cmd/entire/cli/strategy/clean_test.go @@ -76,7 +76,7 @@ func TestListShadowBranches(t *testing.T) { // Create initial commit so we have something to branch from emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -149,7 +149,7 @@ func TestDeleteRefCLI_DeletesPackedCustomRef(t *testing.T) { t.Chdir(dir) emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -195,7 +195,7 @@ func TestDeleteRefCLI_RejectsOIDMismatch(t *testing.T) { t.Chdir(dir) emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -242,7 +242,7 @@ func TestRefStateCLI_ReturnsCurrentOID(t *testing.T) { t.Chdir(dir) emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -311,7 +311,7 @@ func TestListShadowBranches_Empty(t *testing.T) { // Create initial commit emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -353,7 +353,7 @@ func TestDeleteShadowBranches(t *testing.T) { // Create initial commit emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -417,7 +417,7 @@ func TestDeleteShadowBranches_NonExistent(t *testing.T) { // Create initial commit emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -491,7 +491,7 @@ func TestListOrphanedSessionStates_RecentSessionNotOrphaned(t *testing.T) { // Create initial commit emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } @@ -555,7 +555,7 @@ func TestListOrphanedSessionStates_ShadowBranchMatching(t *testing.T) { // Create initial commit emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - commitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "initial commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create initial commit: %v", err) } diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index b76e7b7918..4cd90fe887 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -1584,40 +1584,6 @@ func ExtractSessionIDFromCommit(commit *object.Commit) string { // // See push_common.go and session_test.go for usage examples. -// createCommit creates a commit object -func createCommit(repo *git.Repository, treeHash, parentHash plumbing.Hash, message, authorName, authorEmail string) (plumbing.Hash, error) { //nolint:unparam // already present in codebase - now := time.Now() - sig := object.Signature{ - Name: authorName, - Email: authorEmail, - When: now, - } - - commit := &object.Commit{ - TreeHash: treeHash, - Author: sig, - Committer: sig, - Message: message, - } - - // Add parent if not a new branch - if parentHash != plumbing.ZeroHash { - commit.ParentHashes = []plumbing.Hash{parentHash} - } - - obj := repo.Storer.NewEncodedObject() - if err := commit.Encode(obj); err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) - } - - hash, err := repo.Storer.SetEncodedObject(obj) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to store commit: %w", err) - } - - return hash, nil -} - // getSessionDescriptionFromTree reads the first line of prompt.txt from a git tree. // This is the tree-based equivalent of getSessionDescription (which reads from filesystem). // diff --git a/cmd/entire/cli/strategy/global_test.go b/cmd/entire/cli/strategy/global_test.go index ee67734210..7e715a2859 100644 --- a/cmd/entire/cli/strategy/global_test.go +++ b/cmd/entire/cli/strategy/global_test.go @@ -15,7 +15,9 @@ func TestMain(m *testing.M) { // Register a default ConfigSource so tests that call ConfigScoped // (directly or indirectly via Commit/CreateTag) don't fail with // "no config loader registered". - err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { return config.NewEmpty() }) + err := plugin.Register(plugin.ConfigLoader(), func() plugin.ConfigSource { + return config.NewEmpty() + }) if err != nil { panic(fmt.Errorf("failed to register config storers: %w", err)) } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 1f9f2d3422..d4d71f289b 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -1490,7 +1490,7 @@ func writeTaskMetadataV2IfEnabled( return } - if err := spliceTaskTreeToV2FullCurrent(repo, v2Store, checkpointID, sessionIndex, tasksTree.Hash); err != nil { + if err := spliceTaskTreeToV2FullCurrent(ctx, repo, v2Store, checkpointID, sessionIndex, tasksTree.Hash); err != nil { logging.Warn(ctx, "v2 dual-write task metadata copy failed", slog.String("checkpoint_id", checkpointID.String()), slog.String("session_id", sessionID), @@ -1559,6 +1559,7 @@ func resolveV2SessionIndexForCheckpoint(repo *git.Repository, checkpointID id.Ch } func spliceTaskTreeToV2FullCurrent( + ctx context.Context, repo *git.Repository, v2Store *cpkg.V2GitStore, checkpointID id.CheckpointID, @@ -1589,7 +1590,7 @@ func spliceTaskTreeToV2FullCurrent( } authorName, authorEmail := cpkg.GetGitAuthorFromRepo(repo) - commitHash, err := cpkg.CreateCommit(repo, newRootHash, parentHash, + commitHash, err := cpkg.CreateCommit(ctx, repo, newRootHash, parentHash, fmt.Sprintf("Checkpoint: %s (task metadata)\n", checkpointID), authorName, authorEmail) if err != nil { diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index bf4bdc8cd6..172aa25409 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -138,7 +138,7 @@ func TestShadowStrategy_ListAllSessionStates(t *testing.T) { // Create a dummy commit to use as a base for the shadow branch emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - dummyCommitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com") + dummyCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create dummy commit: %v", err) } @@ -308,7 +308,7 @@ func TestShadowStrategy_FindSessionsForCommit(t *testing.T) { // Create a dummy commit to use as a base for the shadow branches emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - dummyCommitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com") + dummyCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create dummy commit: %v", err) } @@ -1278,7 +1278,7 @@ func TestDeleteShadowBranch(t *testing.T) { // Create a dummy commit to use as branch target emptyTreeHash := plumbing.NewHash("4b825dc642cb6eb9a060e54bf8d69288fbee4904") - dummyCommitHash, err := createCommit(repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com") + dummyCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTreeHash, plumbing.ZeroHash, "dummy commit", "test", "test@test.com") if err != nil { t.Fatalf("failed to create dummy commit: %v", err) } diff --git a/cmd/entire/cli/strategy/metadata_reconcile.go b/cmd/entire/cli/strategy/metadata_reconcile.go index 839bf204b9..bf20a26059 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile.go +++ b/cmd/entire/cli/strategy/metadata_reconcile.go @@ -571,7 +571,7 @@ func cherryPickOnto(ctx context.Context, repo *git.Repository, base plumbing.Has } // Create new commit on top of current tip, preserving original message/author - newHash, err := createCherryPickCommit(repo, mergedTreeHash, currentTip, commit) + newHash, err := createCherryPickCommit(ctx, repo, mergedTreeHash, currentTip, commit) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to create cherry-pick commit: %w", err) } @@ -584,7 +584,7 @@ func cherryPickOnto(ctx context.Context, repo *git.Repository, base plumbing.Has // createCherryPickCommit creates a new commit on top of parent, preserving the // original commit's message and author. -func createCherryPickCommit(repo *git.Repository, treeHash, parent plumbing.Hash, original *object.Commit) (plumbing.Hash, error) { +func createCherryPickCommit(ctx context.Context, repo *git.Repository, treeHash, parent plumbing.Hash, original *object.Commit) (plumbing.Hash, error) { committerName, committerEmail := GetGitAuthorFromRepo(repo) now := time.Now() @@ -600,6 +600,8 @@ func createCherryPickCommit(repo *git.Repository, treeHash, parent plumbing.Hash Message: original.Message, } + checkpoint.SignCommitBestEffort(ctx, commit) + obj := repo.Storer.NewEncodedObject() if err := commit.Encode(obj); err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) diff --git a/cmd/entire/cli/strategy/metadata_reconcile_test.go b/cmd/entire/cli/strategy/metadata_reconcile_test.go index d7549cf4fd..4b97122f05 100644 --- a/cmd/entire/cli/strategy/metadata_reconcile_test.go +++ b/cmd/entire/cli/strategy/metadata_reconcile_test.go @@ -811,7 +811,7 @@ func initBareWithV2MainRef(t *testing.T) string { } treeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, entries) require.NoError(t, err) - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "Checkpoint: abcdef012345", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "Checkpoint: abcdef012345", "test", "test@test.com") require.NoError(t, err) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), commitHash))) @@ -881,7 +881,7 @@ func TestIsV2MainDisconnected_NoRemoteRef(t *testing.T) { require.NoError(t, emptyTree.Encode(treeObj)) treeHash, err := repo.Storer.SetEncodedObject(treeObj) require.NoError(t, err) - commitHash, err := checkpoint.CreateCommit(repo, treeHash, plumbing.ZeroHash, "init v2", "test", "test@test.com") + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, treeHash, plumbing.ZeroHash, "init v2", "test", "test@test.com") require.NoError(t, err) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), commitHash))) @@ -909,7 +909,7 @@ func TestIsV2MainDisconnected_Disconnected(t *testing.T) { } localTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, localEntries) require.NoError(t, err) - localCommitHash, err := checkpoint.CreateCommit(repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com") + localCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com") require.NoError(t, err) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), localCommitHash))) @@ -947,7 +947,7 @@ func TestIsV2MainDisconnected_SharedAncestry(t *testing.T) { } newTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, existing) require.NoError(t, err) - newCommitHash, err := checkpoint.CreateCommit(repo, newTreeHash, ref.Hash(), "local checkpoint 2", "test", "test@test.com") + newCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, newTreeHash, ref.Hash(), "local checkpoint 2", "test", "test@test.com") require.NoError(t, err) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), newCommitHash))) @@ -975,7 +975,7 @@ func TestReconcileDisconnectedV2Ref_CherryPicksOntoRemote(t *testing.T) { } localTreeHash, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, localEntries) require.NoError(t, err) - localCommitHash, err := checkpoint.CreateCommit(repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com") + localCommitHash, err := checkpoint.CreateCommit(context.Background(), repo, localTreeHash, plumbing.ZeroHash, "local checkpoint", "test", "test@test.com") require.NoError(t, err) require.NoError(t, repo.Storer.SetReference(plumbing.NewHashReference(plumbing.ReferenceName(paths.V2MainRefName), localCommitHash))) diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 387269c8db..1caee24a54 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -497,7 +497,7 @@ func isURL(target string) bool { } // createMergeCommitCommon creates a merge commit with multiple parents. -func createMergeCommitCommon(repo *git.Repository, treeHash plumbing.Hash, parents []plumbing.Hash, message string) (plumbing.Hash, error) { +func createMergeCommitCommon(ctx context.Context, repo *git.Repository, treeHash plumbing.Hash, parents []plumbing.Hash, message string) (plumbing.Hash, error) { authorName, authorEmail := GetGitAuthorFromRepo(repo) now := time.Now() sig := object.Signature{ @@ -514,6 +514,8 @@ func createMergeCommitCommon(repo *git.Repository, treeHash plumbing.Hash, paren Message: message, } + checkpoint.SignCommitBestEffort(ctx, commit) + obj := repo.Storer.NewEncodedObject() if err := commit.Encode(obj); err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to encode commit: %w", err) diff --git a/cmd/entire/cli/strategy/push_v2.go b/cmd/entire/cli/strategy/push_v2.go index f9c206e2ad..6539727c25 100644 --- a/cmd/entire/cli/strategy/push_v2.go +++ b/cmd/entire/cli/strategy/push_v2.go @@ -191,7 +191,7 @@ func fetchAndMergeRef(ctx context.Context, target string, refName plumbing.Refer return fmt.Errorf("failed to build merged tree: %w", err) } - mergeCommitHash, err := createMergeCommitCommon(repo, mergedTreeHash, + mergeCommitHash, err := createMergeCommitCommon(ctx, repo, mergedTreeHash, []plumbing.Hash{localRef.Hash(), remoteRef.Hash()}, "Merge remote "+shortRefName(refName)) if err != nil { @@ -323,7 +323,7 @@ func handleRotationConflict(ctx context.Context, target, fetchTarget string, rep } // Create commit parented on archive's commit (fast-forward) - mergeCommitHash, err := createMergeCommitCommon(repo, mergedTreeHash, + mergeCommitHash, err := createMergeCommitCommon(ctx, repo, mergedTreeHash, []plumbing.Hash{archiveRef.Hash()}, "Merge local checkpoints into archived generation") if err != nil { diff --git a/cmd/entire/cli/strategy/push_v2_test.go b/cmd/entire/cli/strategy/push_v2_test.go index d237be1541..528ce574bb 100644 --- a/cmd/entire/cli/strategy/push_v2_test.go +++ b/cmd/entire/cli/strategy/push_v2_test.go @@ -40,7 +40,7 @@ func setupRepoWithV2Ref(t *testing.T) string { emptyTree, err := checkpoint.BuildTreeFromEntries(context.Background(), repo, map[string]object.TreeEntry{}) require.NoError(t, err) - commitHash, err := checkpoint.CreateCommit(repo, emptyTree, plumbing.ZeroHash, + commitHash, err := checkpoint.CreateCommit(context.Background(), repo, emptyTree, plumbing.ZeroHash, "Init v2 main", "Test", "test@test.com") require.NoError(t, err) @@ -323,7 +323,7 @@ func TestFetchAndMergeRef_RotationConflict(t *testing.T) { } archiveTreeHash, err := remoteStore.AddGenerationJSONToTree(currentTreeHash, gen) require.NoError(t, err) - archiveCommitHash, err := checkpoint.CreateCommit(remoteRepo, archiveTreeHash, + archiveCommitHash, err := checkpoint.CreateCommit(context.Background(), remoteRepo, archiveTreeHash, currentRef.Hash(), "Archive", "Test", "test@test.com") require.NoError(t, err) @@ -334,7 +334,7 @@ func TestFetchAndMergeRef_RotationConflict(t *testing.T) { // Create fresh orphan /full/current emptyTree, err := checkpoint.BuildTreeFromEntries(context.Background(), remoteRepo, map[string]object.TreeEntry{}) require.NoError(t, err) - orphanHash, err := checkpoint.CreateCommit(remoteRepo, emptyTree, plumbing.ZeroHash, + orphanHash, err := checkpoint.CreateCommit(context.Background(), remoteRepo, emptyTree, plumbing.ZeroHash, "Start generation", "Test", "test@test.com") require.NoError(t, err) require.NoError(t, remoteRepo.Storer.SetReference( diff --git a/cmd/entire/cli/trail/store.go b/cmd/entire/cli/trail/store.go index d0a25af8b3..7a61e9ad45 100644 --- a/cmd/entire/cli/trail/store.go +++ b/cmd/entire/cli/trail/store.go @@ -51,7 +51,7 @@ func (s *Store) EnsureBranch(ctx context.Context) error { } authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) - commitHash, err := checkpoint.CreateCommit(s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize trails branch", authorName, authorEmail) + commitHash, err := checkpoint.CreateCommit(ctx, s.repo, emptyTreeHash, plumbing.ZeroHash, "Initialize trails branch", authorName, authorEmail) if err != nil { return fmt.Errorf("failed to create initial commit: %w", err) } @@ -98,7 +98,7 @@ func (s *Store) Write(ctx context.Context, metadata *Metadata, discussion *Discu } commitMsg := fmt.Sprintf("Trail: %s (%s)", metadata.Title, metadata.TrailID) - return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) + return s.commitAndUpdateRef(ctx, newTreeHash, commitHash, commitMsg) } // buildTrailEntries creates blob objects for a trail's 3 files and returns them as tree entries. @@ -333,7 +333,7 @@ func (s *Store) AddCheckpoint(ctx context.Context, trailID ID, ref CheckpointRef } commitMsg := fmt.Sprintf("Add checkpoint to trail: %s", trailID) - return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) + return s.commitAndUpdateRef(ctx, newTreeHash, commitHash, commitMsg) } // Delete removes a trail from the entire/trails/v1 branch. @@ -372,7 +372,7 @@ func (s *Store) Delete(ctx context.Context, trailID ID) error { } commitMsg := fmt.Sprintf("Delete trail: %s", trailID) - return s.commitAndUpdateRef(newTreeHash, commitHash, commitMsg) + return s.commitAndUpdateRef(ctx, newTreeHash, commitHash, commitMsg) } // navigateToTrailTree walks rootTree → shard → suffix and returns the trail's subtree. @@ -434,9 +434,9 @@ func (s *Store) readCheckpointsFromTrailTree(trailTree *object.Tree) (*Checkpoin } // commitAndUpdateRef creates a commit and updates the trails branch reference. -func (s *Store) commitAndUpdateRef(treeHash, parentHash plumbing.Hash, message string) error { +func (s *Store) commitAndUpdateRef(ctx context.Context, treeHash, parentHash plumbing.Hash, message string) error { authorName, authorEmail := checkpoint.GetGitAuthorFromRepo(s.repo) - commitHash, err := checkpoint.CreateCommit(s.repo, treeHash, parentHash, message, authorName, authorEmail) + commitHash, err := checkpoint.CreateCommit(ctx, s.repo, treeHash, parentHash, message, authorName, authorEmail) if err != nil { return fmt.Errorf("failed to create commit: %w", err) } diff --git a/cmd/entire/main.go b/cmd/entire/main.go index 057334a7a7..e56b14fff6 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -12,13 +12,11 @@ import ( "github.com/entireio/cli/cmd/entire/cli" "github.com/spf13/cobra" - - // Registers the default Auto ConfigLoader plugin, which lets - // repo.ConfigScoped resolve global/system git config from ~/.gitconfig. - _ "github.com/go-git/go-git/v6/x/plugin" ) func main() { + cli.RegisterObjectSigner() + // Create context that cancels on interrupt ctx, cancel := context.WithCancel(context.Background()) @@ -37,7 +35,6 @@ func main() { // Create and execute root command rootCmd := cli.NewRootCmd() err := rootCmd.ExecuteContext(ctx) - if err != nil { var silent *cli.SilentError diff --git a/docs/architecture/checkpoint-signing.md b/docs/architecture/checkpoint-signing.md new file mode 100644 index 0000000000..8033e69ee9 --- /dev/null +++ b/docs/architecture/checkpoint-signing.md @@ -0,0 +1,40 @@ +# Checkpoint Commit Signing + +Entire can sign checkpoint commits (shadow branch, metadata branch) using the same key configured for regular git commits. Signing is **best-effort**: if the signer is unavailable or fails, the commit is created unsigned and a warning is logged to `.entire/logs/`. + +## Requirements + +All of the following must be true for checkpoint commits to be signed: + +1. **`commit.gpgsign = true`** in git config at **global** or **system** scope. +2. **A supported signer is available**: SSH (via `ssh-agent`) or GPG. +3. **The Entire setting `sign_checkpoint_commits`** is either `true` or not set (defaults to `true`). + +## Supported Signing Formats + +| Format | Config value (`gpg.format`) | Notes | +|--------|----------------------------|-------| +| GPG | `openpgp` (default) | Uses the GPG keyring | +| SSH | `ssh` | Requires a running `ssh-agent` (`SSH_AUTH_SOCK`) | + +The format is determined by the `gpg.format` git config key. When unset, GPG is used. + +## Disabling Signing + +Users may opt-out from checkpoint commit signing. This does not affect signing of regular user commits. + +Add to `.entire/settings.json` (shared with the team) or `.entire/settings.local.json` (personal, gitignored): + +```json +{ + "sign_checkpoint_commits": false +} +``` + +## Best-Effort Behavior + +`SignCommitBestEffort` never blocks a commit from being created. If any step in the signing pipeline fails — signer unavailable, encoding error, signing error — the failure is logged and the commit proceeds unsigned. This ensures that: + +- Users with hardware tokens that require a touch are not blocked during automated checkpoint saves. +- Temporary `ssh-agent` unavailability does not cause data loss. +- CI environments without signing keys continue to work. diff --git a/go.mod b/go.mod index d7bdcd87e8..65a7418231 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/creack/pty v1.1.24 github.com/denisbrodbeck/machineid v1.0.1 github.com/go-git/go-git/v6 v6.0.0-alpha.2 + github.com/go-git/x/plugin/objectsigner/auto v0.0.0-20260330134459-33df49246da9 github.com/google/uuid v1.6.0 github.com/posthog/posthog-go v1.11.3 github.com/sergi/go-diff v1.4.0 @@ -18,6 +19,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/zalando/go-keyring v0.2.8 github.com/zricethezav/gitleaks/v8 v8.30.1 + golang.org/x/crypto v0.50.0 golang.org/x/mod v0.35.0 golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 @@ -60,6 +62,8 @@ require ( github.com/gitleaks/go-gitdiff v0.9.1 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d // indirect + github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 // indirect + github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/h2non/filetype v1.1.3 // indirect @@ -68,6 +72,7 @@ require ( github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hiddeco/sshsig v0.2.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kevinburke/ssh_config v1.6.0 // indirect @@ -114,7 +119,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/multierr v1.11.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index 6ff3137f1c..285c2e9f16 100644 --- a/go.sum +++ b/go.sum @@ -139,6 +139,12 @@ github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsd github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= +github.com/go-git/x/plugin/objectsigner/auto v0.0.0-20260330134459-33df49246da9 h1:kXhj69S4g73PBLPwE/HKaD3a4fGtz3si5wHevmcvJ10= +github.com/go-git/x/plugin/objectsigner/auto v0.0.0-20260330134459-33df49246da9/go.mod h1:iP2cXPyXc//9v9THS3y/MLi0jnt7vEqwUDj11qQfFPg= +github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 h1:NEGVSOD+LPnus6j4iNkAZaHVTc4DNY223y1/I2Jq2yI= +github.com/go-git/x/plugin/objectsigner/gpg v0.1.0/go.mod h1:1iosWq3OOqZxtNrwDHtcjicswuaOT45J5GMFyCk80wc= +github.com/go-git/x/plugin/objectsigner/ssh v0.1.0 h1:lAeeDgc1oxsMMvVUed6ssrqJnD97UR1K/dXIDdeg1Yc= +github.com/go-git/x/plugin/objectsigner/ssh v0.1.0/go.mod h1:6BvpZj9Yry1ZFNw4N5OZDc+7M1T8oyrZilLNFg2aTsM= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -191,6 +197,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= +github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=