Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
79 changes: 71 additions & 8 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
}
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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
Comment thread
pjbgf marked this conversation as resolved.
}

commit.Signature = string(sig)
}
Comment thread
pjbgf marked this conversation as resolved.

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
154 changes: 154 additions & 0 deletions cmd/entire/cli/checkpoint/committed_signing_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 3 additions & 1 deletion cmd/entire/cli/checkpoint/global_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
8 changes: 4 additions & 4 deletions cmd/entire/cli/checkpoint/temporary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading