Skip to content
Open
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
56 changes: 56 additions & 0 deletions cmd/entire/cli/strategy/auto_commit.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -38,7 +39,16 @@ func isNotFoundError(err error) bool {
// commitOrHead attempts to create a commit. If the commit would be empty (files already
// committed), it returns HEAD hash instead. This handles the case where files were
// modified during a session but already committed by the user before the hook runs.
//
// When the user has commit.gpgsign=true in their git config, this falls back to the
// git CLI so that GPG, SSH, or X.509 signing is applied automatically.
func commitOrHead(repo *git.Repository, worktree *git.Worktree, msg string, author *object.Signature) (plumbing.Hash, error) {
// When commit signing is enabled, use git CLI to let git handle
// GPG, SSH, or X.509 signing automatically (go-git only supports OpenPGP)
if shouldSignCommits() {
return commitWithCLI(repo, msg, author)
}

commitHash, err := worktree.Commit(msg, &git.CommitOptions{Author: author})
if errors.Is(err, git.ErrEmptyCommit) {
fmt.Fprintf(os.Stderr, "No changes to commit (files already committed)\n")
Expand All @@ -54,6 +64,52 @@ func commitOrHead(repo *git.Repository, worktree *git.Worktree, msg string, auth
return commitHash, nil
}

// shouldSignCommits checks if the user has commit signing enabled via git config.
// Uses git CLI to respect all config scopes (local, global, system, includes).
func shouldSignCommits() bool {
ctx := context.Background()
cmd := exec.CommandContext(ctx, "git", "config", "--get", "commit.gpgsign")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(output)) == "true"
}

// commitWithCLI creates a git commit using the git CLI instead of go-git.
// This is used when commit.gpgsign is enabled, as the git CLI handles
// GPG, SSH, and X.509 signing automatically based on the user's config.
func commitWithCLI(repo *git.Repository, msg string, author *object.Signature) (plumbing.Hash, error) {
ctx := context.Background()
authorStr := fmt.Sprintf("%s <%s>", author.Name, author.Email)

cmd := exec.CommandContext(ctx, "git", "commit", "-m", msg, "--author", authorStr) //nolint:gosec // authorStr is from git config, not user input
output, err := cmd.CombinedOutput()
if err != nil {
outputStr := string(output)
// Handle empty commit (files already committed)
if strings.Contains(outputStr, "nothing to commit") ||
strings.Contains(outputStr, "nothing added to commit") {
fmt.Fprintf(os.Stderr, "No changes to commit (files already committed)\n")
head, headErr := repo.Head()
if headErr != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get HEAD: %w", headErr)
}
return head.Hash(), nil
}
return plumbing.ZeroHash, fmt.Errorf("git commit failed: %s: %w", strings.TrimSpace(outputStr), err)
}

// Use git rev-parse to get the new HEAD hash since go-git may have
// a stale cached reference after a CLI commit
revCmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
revOutput, err := revCmd.Output()
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("failed to get HEAD after commit: %w", err)
}
return plumbing.NewHash(strings.TrimSpace(string(revOutput))), nil
}

// AutoCommitStrategy implements the auto-commit strategy:
// - Code changes are committed to the active branch (like commit strategy)
// - Session logs are committed to a shadow branch (like manual-commit strategy)
Expand Down
203 changes: 203 additions & 0 deletions cmd/entire/cli/strategy/auto_commit_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package strategy

import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/trailers"
Expand Down Expand Up @@ -1035,3 +1038,203 @@ func TestAutoCommitStrategy_SaveChanges_NoChangesSkipped(t *testing.T) {
sessionsCommitBefore, sessionsRefAfter.Hash())
}
}

// initGitRepoWithCLI initializes a git repo using the CLI and configures user identity.
// Returns the repo directory. This is needed for tests that exercise git CLI code paths
// (like commitWithCLI), since repos created with go-git's PlainInit may not have the
// full config that the git CLI expects.
func initGitRepoWithCLI(t *testing.T) string {
t.Helper()
dir := t.TempDir()
ctx := context.Background()

cmd := exec.CommandContext(ctx, "git", "init")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git init failed: %v", err)
}

cmd = exec.CommandContext(ctx, "git", "config", "user.email", "test@test.com")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git config user.email failed: %v", err)
}

cmd = exec.CommandContext(ctx, "git", "config", "user.name", "Test User")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git config user.name failed: %v", err)
}

// Disable GPG signing by default for test isolation
cmd = exec.CommandContext(ctx, "git", "config", "commit.gpgsign", "false")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git config commit.gpgsign failed: %v", err)
}

return dir
}

func TestShouldSignCommits_Disabled(t *testing.T) {
dir := initGitRepoWithCLI(t)
t.Chdir(dir)

if shouldSignCommits() {
t.Error("shouldSignCommits() = true, want false when gpgsign is disabled")
}
}

func TestShouldSignCommits_Enabled(t *testing.T) {
dir := initGitRepoWithCLI(t)
t.Chdir(dir)

// Enable signing
ctx := context.Background()
cmd := exec.CommandContext(ctx, "git", "config", "commit.gpgsign", "true")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git config commit.gpgsign failed: %v", err)
}

if !shouldSignCommits() {
t.Error("shouldSignCommits() = false, want true when gpgsign is enabled")
}
}

func TestCommitWithCLI_CreatesCommit(t *testing.T) {
dir := initGitRepoWithCLI(t)
t.Chdir(dir)

// Create initial commit via CLI
ctx := context.Background()
readmeFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
t.Fatalf("failed to write README: %v", err)
}
cmd := exec.CommandContext(ctx, "git", "add", ".")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git add failed: %v", err)
}
cmd = exec.CommandContext(ctx, "git", "commit", "-m", "initial")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}

// Open repo with go-git for commitWithCLI
repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{
EnableDotGitCommonDir: true,
})
if err != nil {
t.Fatalf("failed to open repo: %v", err)
}

headBefore, err := repo.Head()
if err != nil {
t.Fatalf("failed to get HEAD: %v", err)
}

// Create a new file and stage it
testFile := filepath.Join(dir, "test.go")
if err := os.WriteFile(testFile, []byte("package main"), 0o644); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
worktree, err := repo.Worktree()
if err != nil {
t.Fatalf("failed to get worktree: %v", err)
}
if _, err := worktree.Add("test.go"); err != nil {
t.Fatalf("failed to add test file: %v", err)
}

// Commit via CLI
author := &object.Signature{
Name: "Test User",
Email: "test@test.com",
When: time.Now(),
}
commitHash, err := commitWithCLI(repo, "test commit via CLI", author)
if err != nil {
t.Fatalf("commitWithCLI() error = %v", err)
}

// Verify a new commit was created
if commitHash == headBefore.Hash() {
t.Error("commitWithCLI() returned same hash as HEAD before, expected new commit")
}
if commitHash == plumbing.ZeroHash {
t.Error("commitWithCLI() returned zero hash")
}

// Verify the commit message and author via git log
cmd = exec.CommandContext(ctx, "git", "log", "-1", "--format=%s%n%an%n%ae")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) < 3 {
t.Fatalf("unexpected git log output: %q", string(output))
}
if lines[0] != "test commit via CLI" {
t.Errorf("commit subject = %q, want %q", lines[0], "test commit via CLI")
}
if lines[1] != "Test User" {
t.Errorf("commit author name = %q, want %q", lines[1], "Test User")
}
if lines[2] != "test@test.com" {
t.Errorf("commit author email = %q, want %q", lines[2], "test@test.com")
}
}

func TestCommitWithCLI_EmptyCommit(t *testing.T) {
dir := initGitRepoWithCLI(t)
t.Chdir(dir)

// Create initial commit via CLI
ctx := context.Background()
readmeFile := filepath.Join(dir, "README.md")
if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
t.Fatalf("failed to write README: %v", err)
}
cmd := exec.CommandContext(ctx, "git", "add", ".")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git add failed: %v", err)
}
cmd = exec.CommandContext(ctx, "git", "commit", "-m", "initial")
cmd.Dir = dir
if err := cmd.Run(); err != nil {
t.Fatalf("git commit failed: %v", err)
}

// Open repo with go-git
repo, err := git.PlainOpenWithOptions(dir, &git.PlainOpenOptions{
EnableDotGitCommonDir: true,
})
if err != nil {
t.Fatalf("failed to open repo: %v", err)
}

headBefore, err := repo.Head()
if err != nil {
t.Fatalf("failed to get HEAD: %v", err)
}

// Attempt commit with no staged changes — should return HEAD hash, no error
author := &object.Signature{
Name: "Test User",
Email: "test@test.com",
When: time.Now(),
}
commitHash, err := commitWithCLI(repo, "empty commit", author)
if err != nil {
t.Fatalf("commitWithCLI() error = %v, want nil for empty commit", err)
}
if commitHash != headBefore.Hash() {
t.Errorf("commitWithCLI() = %s, want HEAD %s for empty commit", commitHash, headBefore.Hash())
}
}