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
189 changes: 189 additions & 0 deletions cmd/entire/cli/strategy/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,195 @@ func TestGetGitDirInPath_NotARepo(t *testing.T) {
}
}

func TestIsGitSequenceOperation_NoOperation(t *testing.T) {
// Create a temp directory and initialize a real git repo
tmpDir := t.TempDir()
t.Chdir(tmpDir)

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

// No sequence operation in progress
if isGitSequenceOperation() {
t.Error("isGitSequenceOperation() = true, want false for clean repo")
}
}

func TestIsGitSequenceOperation_RebaseMerge(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)

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

// Simulate rebase-merge state
rebaseMergeDir := filepath.Join(tmpDir, ".git", "rebase-merge")
if err := os.MkdirAll(rebaseMergeDir, 0o755); err != nil {
t.Fatalf("failed to create rebase-merge dir: %v", err)
}

if !isGitSequenceOperation() {
t.Error("isGitSequenceOperation() = false, want true during rebase-merge")
}
}

func TestIsGitSequenceOperation_RebaseApply(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)

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

// Simulate rebase-apply state
rebaseApplyDir := filepath.Join(tmpDir, ".git", "rebase-apply")
if err := os.MkdirAll(rebaseApplyDir, 0o755); err != nil {
t.Fatalf("failed to create rebase-apply dir: %v", err)
}

if !isGitSequenceOperation() {
t.Error("isGitSequenceOperation() = false, want true during rebase-apply")
}
}

func TestIsGitSequenceOperation_CherryPick(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)

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

// Simulate cherry-pick state
cherryPickHead := filepath.Join(tmpDir, ".git", "CHERRY_PICK_HEAD")
if err := os.WriteFile(cherryPickHead, []byte("abc123"), 0o644); err != nil {
t.Fatalf("failed to create CHERRY_PICK_HEAD: %v", err)
}

if !isGitSequenceOperation() {
t.Error("isGitSequenceOperation() = false, want true during cherry-pick")
}
}

func TestIsGitSequenceOperation_Revert(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)

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

// Simulate revert state
revertHead := filepath.Join(tmpDir, ".git", "REVERT_HEAD")
if err := os.WriteFile(revertHead, []byte("abc123"), 0o644); err != nil {
t.Fatalf("failed to create REVERT_HEAD: %v", err)
}

if !isGitSequenceOperation() {
t.Error("isGitSequenceOperation() = false, want true during revert")
}
}

func TestIsGitSequenceOperation_Worktree(t *testing.T) {
// Test that detection works in a worktree (git dir is different)
tmpDir := t.TempDir()
mainRepo := filepath.Join(tmpDir, "main")
worktreeDir := filepath.Join(tmpDir, "worktree")

if err := os.MkdirAll(mainRepo, 0o755); err != nil {
t.Fatalf("failed to create main repo dir: %v", err)
}

ctx := context.Background()

// Initialize main repo with a commit
cmd := exec.CommandContext(ctx, "git", "init")
cmd.Dir = mainRepo
if err := cmd.Run(); err != nil {
t.Fatalf("failed to init main repo: %v", err)
}

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

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

testFile := filepath.Join(mainRepo, "test.txt")
if err := os.WriteFile(testFile, []byte("test"), 0o644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}

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

cmd = exec.CommandContext(ctx, "git", "commit", "-m", "initial")
cmd.Dir = mainRepo
if err := cmd.Run(); err != nil {
t.Fatalf("failed to git commit: %v", err)
}

// Create a worktree
cmd = exec.CommandContext(ctx, "git", "worktree", "add", worktreeDir, "-b", "feature")
cmd.Dir = mainRepo
if err := cmd.Run(); err != nil {
t.Fatalf("failed to create worktree: %v", err)
}

// Change to worktree
t.Chdir(worktreeDir)

// Should not detect sequence operation in clean worktree
if isGitSequenceOperation() {
t.Error("isGitSequenceOperation() = true in clean worktree, want false")
}

// Get the worktree's git dir and simulate rebase state there
cmd = exec.CommandContext(ctx, "git", "rev-parse", "--git-dir")
cmd.Dir = worktreeDir
output, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get git dir: %v", err)
}
gitDir := strings.TrimSpace(string(output))

rebaseMergeDir := filepath.Join(gitDir, "rebase-merge")
if err := os.MkdirAll(rebaseMergeDir, 0o755); err != nil {
t.Fatalf("failed to create rebase-merge dir in worktree: %v", err)
}

// Now should detect sequence operation
if !isGitSequenceOperation() {
t.Error("isGitSequenceOperation() = false in worktree during rebase, want true")
}
}

func TestInstallGitHook_Idempotent(t *testing.T) {
// Create a temp directory and initialize a real git repo
tmpDir := t.TempDir()
Expand Down
45 changes: 45 additions & 0 deletions cmd/entire/cli/strategy/manual_commit_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"

"entire.io/cli/cmd/entire/cli/agent"
Expand Down Expand Up @@ -133,6 +134,40 @@ func stripCheckpointTrailer(message string) string {
return strings.Join(result, "\n")
}

// isGitSequenceOperation checks if git is currently in the middle of a rebase,
// cherry-pick, or revert operation. During these operations, commits are being
// replayed and should not be linked to agent sessions.
//
// Detects:
// - rebase: .git/rebase-merge/ or .git/rebase-apply/ directories
// - cherry-pick: .git/CHERRY_PICK_HEAD file
// - revert: .git/REVERT_HEAD file
func isGitSequenceOperation() bool {
// Get git directory (handles worktrees and relative paths correctly)
gitDir, err := GetGitDir()
if err != nil {
return false // Can't determine, assume not in sequence operation
}

// Check for rebase state directories
if _, err := os.Stat(filepath.Join(gitDir, "rebase-merge")); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(gitDir, "rebase-apply")); err == nil {
return true
}

// Check for cherry-pick and revert state files
if _, err := os.Stat(filepath.Join(gitDir, "CHERRY_PICK_HEAD")); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(gitDir, "REVERT_HEAD")); err == nil {
return true
}

return false
}

// PrepareCommitMsg is called by the git prepare-commit-msg hook.
// Adds an Entire-Checkpoint trailer to the commit message with a stable checkpoint ID.
// Only adds a trailer if there's actually new session content to condense.
Expand All @@ -149,6 +184,16 @@ func stripCheckpointTrailer(message string) string {
func (s *ManualCommitStrategy) PrepareCommitMsg(commitMsgFile string, source string) error {
logCtx := logging.WithComponent(context.Background(), "checkpoint")

// Skip during rebase, cherry-pick, or revert operations
// These are replaying existing commits and should not be linked to agent sessions
if isGitSequenceOperation() {
logging.Debug(logCtx, "prepare-commit-msg: skipped during git sequence operation",
slog.String("strategy", "manual-commit"),
slog.String("source", source),
)
return nil
}

// Skip for merge, squash, and commit (amend) sources
// These are auto-generated or reusing existing messages - not from Claude sessions
switch source {
Expand Down