Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ The manual-commit strategy (`manual_commit*.go`) does not modify the active bran
- **Worktree-specific branches** - each git worktree gets its own shadow branch namespace, preventing conflicts
- **Supports multiple concurrent sessions** - checkpoints from different sessions in the same directory interleave on the same shadow branch
- Condenses session logs to permanent `entire/checkpoints/v1` branch on user commits
- Uses the `post-rewrite` Git hook to keep local session linkage aligned after amend/rebase rewrites
- Builds git trees in-memory using go-git plumbing APIs
- Rewind restores files from shadow branch commit tree (does not use `git reset`)
- **Location-independent transcript resolution** - transcript paths are always computed dynamically from the current repo location (via `agent.GetSessionDir` + `agent.ResolveSessionFile`), never stored in checkpoint metadata. This ensures restore/rewind works after repo relocation or across machines.
Expand All @@ -374,7 +375,7 @@ The manual-commit strategy (`manual_commit*.go`) does not modify the active bran
- `manual_commit_rewind.go` - Rewind implementation: file restoration from checkpoint trees
- `manual_commit_git.go` - Git operations: checkpoint commits, tree building
- `manual_commit_logs.go` - Session log retrieval and session listing
- `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, post-commit, pre-push)
- `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, post-commit, post-rewrite, pre-push)
- `manual_commit_reset.go` - Shadow branch reset/cleanup functionality
- `session_state.go` - Package-level session state functions (`LoadSessionState`, `SaveSessionState`, `ListSessionStates`, `FindMostRecentSession`)
- `hooks.go` - Git hook installation
Expand Down
23 changes: 23 additions & 0 deletions cmd/entire/cli/hooks_git_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func newHooksGitCmd() *cobra.Command {
cmd.AddCommand(newHooksGitPrepareCommitMsgCmd())
cmd.AddCommand(newHooksGitCommitMsgCmd())
cmd.AddCommand(newHooksGitPostCommitCmd())
cmd.AddCommand(newHooksGitPostRewriteCmd())
cmd.AddCommand(newHooksGitPrePushCmd())

return cmd
Expand Down Expand Up @@ -202,6 +203,28 @@ func newHooksGitPostCommitCmd() *cobra.Command {
}
}

func newHooksGitPostRewriteCmd() *cobra.Command {
return &cobra.Command{
Use: "post-rewrite <rewrite-type>",
Short: "Handle post-rewrite git hook",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if gitHooksDisabled {
return nil
}

g := newGitHookContext(cmd.Context(), "post-rewrite")
defer g.span.End()
g.logInvoked(slog.String("rewrite_type", args[0]))

hookErr := g.strategy.PostRewrite(g.ctx, args[0], cmd.InOrStdin())
g.logCompleted(hookErr)

return nil
},
}
}

func newHooksGitPrePushCmd() *cobra.Command {
return &cobra.Command{
Use: "pre-push <remote>",
Expand Down
16 changes: 16 additions & 0 deletions cmd/entire/cli/hooks_git_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,19 @@ func TestHooksGitCmd_DiscoverExternalAgents_WhenEnabled(t *testing.T) {
t.Errorf("expected external agent %q to be registered after hook pre-run, got: %v", agentName, err)
}
}

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

cmd := newHooksGitCmd()
found, _, err := cmd.Find([]string{"post-rewrite"})
if err != nil {
t.Fatalf("could not find post-rewrite subcommand: %v", err)
}
if found == nil {
t.Fatal("expected post-rewrite subcommand, got nil")
}
if found.Use != "post-rewrite <rewrite-type>" {
t.Fatalf("post-rewrite Use = %q, want %q", found.Use, "post-rewrite <rewrite-type>")
}
}
220 changes: 220 additions & 0 deletions cmd/entire/cli/integration_test/phase_transitions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package integration

import (
"os/exec"
"testing"

"github.com/entireio/cli/cmd/entire/cli/paths"
Expand Down Expand Up @@ -305,3 +306,222 @@ func TestShadow_AmendPreservesTrailer(t *testing.T) {

t.Log("AmendPreservesTrailer test completed successfully")
}

// TestShadow_PostRewriteAmendRemapsSessionState verifies that the git
// post-rewrite hook updates local session linkage after an amend rewrites the
// commit SHA.
func TestShadow_PostRewriteAmendRemapsSessionState(t *testing.T) {
t.Parallel()

env := NewFeatureBranchEnv(t)

sess := env.NewSession()
if err := env.SimulateUserPromptSubmit(sess.ID); err != nil {
t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
}

env.WriteFile("main.go", "package main\n\nfunc main() {}\n")
sess.CreateTranscript("Create main.go", []FileChange{
{Path: "main.go", Content: "package main\n\nfunc main() {}\n"},
})
if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateStop failed: %v", err)
}

env.GitCommitWithShadowHooks("Initial implementation", "main.go")

originalCommitHash := env.GetHeadHash()
originalCheckpointID := env.GetCheckpointIDFromCommitMessage(originalCommitHash)
if originalCheckpointID == "" {
t.Fatal("Original commit should have a checkpoint trailer")
}

stateBeforeAmend, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState failed: %v", err)
}
if stateBeforeAmend == nil {
t.Fatal("Session state should exist")
}
if stateBeforeAmend.BaseCommit != originalCommitHash {
t.Fatalf("BaseCommit before amend = %q, want %q", stateBeforeAmend.BaseCommit, originalCommitHash)
}

env.GitCommitAmendWithShadowHooks("Initial implementation (amended)")
amendedCommitHash := env.GetHeadHash()
if amendedCommitHash == originalCommitHash {
t.Fatal("Amended commit should have a different hash")
}

stateAfterAmend, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState after amend failed: %v", err)
}
if stateAfterAmend == nil {
t.Fatal("Session state should exist after amend")
}
if stateAfterAmend.BaseCommit != originalCommitHash {
t.Fatalf("BaseCommit after amend = %q, want original %q before post-rewrite", stateAfterAmend.BaseCommit, originalCommitHash)
}

env.GitPostRewriteWithShadowHooks("amend", [2]string{originalCommitHash, amendedCommitHash})

stateAfterRewrite, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState after post-rewrite failed: %v", err)
}
if stateAfterRewrite == nil {
t.Fatal("Session state should exist after post-rewrite")
}
if stateAfterRewrite.BaseCommit != amendedCommitHash {
t.Fatalf("BaseCommit after post-rewrite = %q, want %q", stateAfterRewrite.BaseCommit, amendedCommitHash)
}
if stateAfterRewrite.AttributionBaseCommit != amendedCommitHash {
t.Fatalf("AttributionBaseCommit after post-rewrite = %q, want %q", stateAfterRewrite.AttributionBaseCommit, amendedCommitHash)
}
if stateAfterRewrite.LastCheckpointID.String() != originalCheckpointID {
t.Fatalf("LastCheckpointID after post-rewrite = %q, want %q", stateAfterRewrite.LastCheckpointID.String(), originalCheckpointID)
}
}

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

env := NewFeatureBranchEnv(t)

sess := env.NewSession()
if err := env.SimulateUserPromptSubmit(sess.ID); err != nil {
t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
}

env.WriteFile("main.go", "package main\n\nfunc main() {}\n")
sess.CreateTranscript("Create main.go", []FileChange{
{Path: "main.go", Content: "package main\n\nfunc main() {}\n"},
})
if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateStop failed: %v", err)
}

originalCommitHash := env.GetHeadHash()
originalShadowBranch := env.GetShadowBranchNameForCommit(originalCommitHash)
if !env.BranchExists(originalShadowBranch) {
t.Fatalf("expected original shadow branch %q to exist", originalShadowBranch)
}

env.GitCommitAmendWithShadowHooks("Initial commit (amended)")

amendedCommitHash := env.GetHeadHash()
if amendedCommitHash == originalCommitHash {
t.Fatal("Amended commit should have a different hash")
}

env.GitPostRewriteWithShadowHooks("amend", [2]string{originalCommitHash, amendedCommitHash})

newShadowBranch := env.GetShadowBranchNameForCommit(amendedCommitHash)
if !env.BranchExists(newShadowBranch) {
t.Fatalf("expected migrated shadow branch %q to exist", newShadowBranch)
}
if env.BranchExists(originalShadowBranch) {
t.Fatalf("expected original shadow branch %q to be removed", originalShadowBranch)
}

stateAfterRewrite, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState after post-rewrite failed: %v", err)
}
if stateAfterRewrite == nil {
t.Fatal("Session state should exist after post-rewrite")
}
if stateAfterRewrite.BaseCommit != amendedCommitHash {
t.Fatalf("BaseCommit after post-rewrite = %q, want %q", stateAfterRewrite.BaseCommit, amendedCommitHash)
}
if stateAfterRewrite.AttributionBaseCommit != originalCommitHash {
t.Fatalf("AttributionBaseCommit after post-rewrite = %q, want original %q when shadow branch migrates", stateAfterRewrite.AttributionBaseCommit, originalCommitHash)
}
}

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

env := NewTestEnv(t)
defer env.Cleanup()

env.InitRepo()
env.WriteFile("README.md", "base\n")
env.GitAdd("README.md")
env.GitCommit("Initial commit")

env.GitCheckoutNewBranch("feature/post-rewrite-rebase")
env.InitEntire()

sess := env.NewSession()
if err := env.SimulateUserPromptSubmit(sess.ID); err != nil {
t.Fatalf("SimulateUserPromptSubmit failed: %v", err)
}

env.WriteFile("feature.txt", "feature work\n")
sess.CreateTranscript("Add feature file", []FileChange{
{Path: "feature.txt", Content: "feature work\n"},
})
if err := env.SimulateStop(sess.ID, sess.TranscriptPath); err != nil {
t.Fatalf("SimulateStop failed: %v", err)
}

env.GitCommitWithShadowHooks("Feature work", "feature.txt")
originalFeatureCommit := env.GetHeadHash()

stateBeforeRebase, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState failed: %v", err)
}
if stateBeforeRebase == nil {
t.Fatal("Session state should exist")
}
if stateBeforeRebase.BaseCommit != originalFeatureCommit {
t.Fatalf("BaseCommit before rebase = %q, want %q", stateBeforeRebase.BaseCommit, originalFeatureCommit)
}

env.gitCheckout("master")
env.WriteFile("upstream.txt", "upstream\n")
env.GitAdd("upstream.txt")
env.GitCommit("Upstream change")
env.gitCheckout("feature/post-rewrite-rebase")

cmd := exec.Command("git", "rebase", "master")
cmd.Dir = env.RepoDir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git rebase failed: %v\nOutput: %s", err, output)
}

rebasedFeatureCommit := env.GetHeadHash()
if rebasedFeatureCommit == originalFeatureCommit {
t.Fatal("Rebased commit should have a different hash")
}

stateAfterRebase, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState after rebase failed: %v", err)
}
if stateAfterRebase == nil {
t.Fatal("Session state should exist after rebase")
}
if stateAfterRebase.BaseCommit != originalFeatureCommit {
t.Fatalf("BaseCommit after rebase = %q, want original %q before post-rewrite", stateAfterRebase.BaseCommit, originalFeatureCommit)
}

env.GitPostRewriteWithShadowHooks("rebase", [2]string{originalFeatureCommit, rebasedFeatureCommit})

stateAfterRewrite, err := env.GetSessionState(sess.ID)
if err != nil {
t.Fatalf("GetSessionState after post-rewrite failed: %v", err)
}
if stateAfterRewrite == nil {
t.Fatal("Session state should exist after post-rewrite")
}
if stateAfterRewrite.BaseCommit != rebasedFeatureCommit {
t.Fatalf("BaseCommit after post-rewrite = %q, want %q", stateAfterRewrite.BaseCommit, rebasedFeatureCommit)
}
if stateAfterRewrite.AttributionBaseCommit != rebasedFeatureCommit {
t.Fatalf("AttributionBaseCommit after post-rewrite = %q, want %q", stateAfterRewrite.AttributionBaseCommit, rebasedFeatureCommit)
}
}
22 changes: 22 additions & 0 deletions cmd/entire/cli/integration_test/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,28 @@ func (env *TestEnv) GitCommitAmendWithShadowHooks(message string, files ...strin
}
}

// GitPostRewriteWithShadowHooks runs the git post-rewrite hook with the provided
// old->new commit mappings. Each mapping is a pair of commit SHAs.
func (env *TestEnv) GitPostRewriteWithShadowHooks(rewriteType string, mappings ...[2]string) {
env.T.Helper()

var input strings.Builder
for _, mapping := range mappings {
input.WriteString(mapping[0])
input.WriteByte(' ')
input.WriteString(mapping[1])
input.WriteByte('\n')
}

cmd := exec.Command(getTestBinary(), "hooks", "git", "post-rewrite", rewriteType)
cmd.Dir = env.RepoDir
cmd.Env = env.gitHookEnv()
cmd.Stdin = strings.NewReader(input.String())
if output, err := cmd.CombinedOutput(); err != nil {
env.T.Fatalf("post-rewrite hook failed: %v\nOutput: %s", err, output)
}
}

// GitCommitWithTrailerRemoved stages and commits files, simulating what happens when
// a user removes the Entire-Checkpoint trailer during commit message editing.
// This tests the opt-out behavior where removing the trailer skips condensation.
Expand Down
6 changes: 3 additions & 3 deletions cmd/entire/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ func TestPersistentPostRun_SkipsHiddenParent(t *testing.T) {

root := NewRootCmd()

// Find the leaf command: entire hooks git post-commit
// Find the leaf command: entire hooks git post-rewrite
// This exercises the real command tree where "hooks" is Hidden but its descendants are not.
leaf, _, err := root.Find([]string{"hooks", "git", "post-commit"})
leaf, _, err := root.Find([]string{"hooks", "git", "post-rewrite"})
if err != nil {
t.Fatalf("could not find hooks git post-commit command: %v", err)
t.Fatalf("could not find hooks git post-rewrite command: %v", err)
}

if leaf.Hidden {
Expand Down
10 changes: 9 additions & 1 deletion cmd/entire/cli/strategy/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const backupSuffix = ".pre-entire"
const chainComment = "# Chain: run pre-existing hook"

// gitHookNames are the git hooks managed by Entire CLI
var gitHookNames = []string{"prepare-commit-msg", "commit-msg", "post-commit", "pre-push"}
var gitHookNames = []string{"prepare-commit-msg", "commit-msg", "post-commit", "post-rewrite", "pre-push"}

// ManagedGitHookNames returns the list of git hooks managed by Entire CLI.
// This is useful for tests that need to manipulate hooks.
Expand Down Expand Up @@ -188,6 +188,14 @@ func buildHookSpecs(cmdPrefix string) []hookSpec {
# %s
# Post-commit hook: condense session data if commit has Entire-Checkpoint trailer
%s hooks git post-commit 2>/dev/null || true
`, entireHookMarker, cmdPrefix),
},
{
name: "post-rewrite",
content: fmt.Sprintf(`#!/bin/sh
# %s
# Post-rewrite hook: remap session linkage after amend/rebase rewrites
%s hooks git post-rewrite "$1" 2>/dev/null || true
`, entireHookMarker, cmdPrefix),
},
{
Expand Down
Loading
Loading