Skip to content
Draft
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
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
Loading
Loading