From 53500cc34eca11e2b6209b5c692a29164a7efde1 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Wed, 18 Feb 2026 16:26:25 +1100 Subject: [PATCH 01/11] delete auto-commit strat Entire-Checkpoint: 9257c9f8b2a3 --- .claude/skills/test-repo/SKILL.md | 34 +- .claude/skills/test-repo/test-harness.sh | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 - CLAUDE.md | 23 +- README.md | 80 +- cmd/entire/cli/checkpoint/checkpoint.go | 2 +- cmd/entire/cli/clean.go | 4 +- cmd/entire/cli/config.go | 22 +- cmd/entire/cli/config_test.go | 87 +- cmd/entire/cli/debug.go | 377 ------ .../e2e_test/scenario_agent_commit_test.go | 4 +- .../e2e_test/scenario_basic_workflow_test.go | 4 +- .../cli/e2e_test/scenario_checkpoint_test.go | 56 +- .../scenario_checkpoint_workflows_test.go | 36 +- .../cli/e2e_test/scenario_rewind_test.go | 6 +- .../cli/e2e_test/scenario_subagent_test.go | 4 +- cmd/entire/cli/e2e_test/testenv.go | 7 +- cmd/entire/cli/explain_test.go | 16 +- .../auto_commit_checkpoint_fix_test.go | 318 ----- cmd/entire/cli/integration_test/hooks_test.go | 45 +- .../cli/integration_test/resume_test.go | 37 +- .../cli/integration_test/rewind_test.go | 46 - .../subagent_checkpoints_test.go | 14 +- cmd/entire/cli/integration_test/testenv.go | 3 +- .../cli/integration_test/testenv_test.go | 11 +- .../cli/integration_test/worktree_test.go | 4 +- cmd/entire/cli/lifecycle.go | 45 +- cmd/entire/cli/paths/paths.go | 2 +- cmd/entire/cli/reset.go | 2 - cmd/entire/cli/reset_test.go | 24 - cmd/entire/cli/resume_test.go | 71 +- cmd/entire/cli/root.go | 4 +- cmd/entire/cli/settings/settings.go | 29 +- cmd/entire/cli/settings/settings_test.go | 8 +- cmd/entire/cli/setup.go | 175 +-- cmd/entire/cli/setup_test.go | 87 -- cmd/entire/cli/status.go | 11 +- cmd/entire/cli/status_test.go | 34 +- cmd/entire/cli/strategy/auto_commit.go | 1105 ----------------- cmd/entire/cli/strategy/auto_commit_test.go | 1037 ---------------- cmd/entire/cli/strategy/clean_test.go | 2 +- cmd/entire/cli/strategy/common.go | 99 +- cmd/entire/cli/strategy/push_common.go | 1 - cmd/entire/cli/strategy/registry.go | 24 - cmd/entire/cli/strategy/rewind_test.go | 33 - cmd/entire/cli/strategy/strategy.go | 11 +- docs/architecture/claude-hooks-integration.md | 3 +- docs/architecture/logging.md | 1 - docs/architecture/sessions-and-checkpoints.md | 7 - 49 files changed, 216 insertions(+), 3843 deletions(-) delete mode 100644 cmd/entire/cli/debug.go delete mode 100644 cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go delete mode 100644 cmd/entire/cli/strategy/auto_commit.go delete mode 100644 cmd/entire/cli/strategy/auto_commit_test.go diff --git a/.claude/skills/test-repo/SKILL.md b/.claude/skills/test-repo/SKILL.md index ca5ad29e7..a9291b6d6 100644 --- a/.claude/skills/test-repo/SKILL.md +++ b/.claude/skills/test-repo/SKILL.md @@ -10,7 +10,7 @@ This skill validates the CLI's session management and rewind functionality by ru ## When to Use - User asks to "test against a test repo" -- User wants to validate strategy changes (manual-commit, auto-commit, shadow, dual) +- User wants to validate strategy changes (manual-commit) - User asks to verify session hooks, commits, or rewind functionality - After making changes to strategy code @@ -54,7 +54,7 @@ Add this pattern to your Claude Code approved commands, or approve it once when **Optional: Set strategy** (defaults to `manual-commit`): ```bash -export STRATEGY=manual-commit # or auto-commit, shadow, dual +export STRATEGY=manual-commit ``` ### Test Steps @@ -87,15 +87,15 @@ Execute these steps in order: .claude/skills/test-repo/test-harness.sh list-rewind-points ``` -Expected results by strategy: +Expected results: -| Check | manual-commit/shadow | auto-commit/dual | -|-------|---------------------|------------------| -| Active branch | No Entire-* trailers | Entire-Checkpoint: trailer only | -| Session state | ✓ Exists | ✗ Not used | -| Shadow branch | ✓ entire/{hash} | ✗ None | -| Metadata branch | ✓ entire/checkpoints/v1 | ✓ entire/checkpoints/v1 | -| Rewind points | ✓ At least 1 | ✓ At least 1 | +| Check | Result | +|-------|--------| +| Active branch | Optional Entire-Checkpoint: trailer | +| Session state | ✓ Exists | +| Shadow branch | ✓ entire/{hash} | +| Metadata branch | ✓ entire/checkpoints/v1 | +| Rewind points | ✓ At least 1 | #### 4. Test Rewind @@ -107,8 +107,7 @@ Expected results by strategy: ``` **Expected Behavior:** -- **Manual-commit/shadow**: Shows warning listing untracked files that will be deleted (files created after the checkpoint that weren't present at session start) -- **Auto-commit/dual**: No warning (git reset doesn't delete untracked files) +- Shows warning listing untracked files that will be deleted (files created after the checkpoint that weren't present at session start) Example warning output (manual-commit): ``` @@ -144,7 +143,7 @@ go build -o /tmp/entire-bin ./cmd/entire && \ ## Expected Results by Strategy -### Manual-Commit Strategy (default, alias: shadow) +### Manual-Commit Strategy (default) - Active branch commits: **NO modifications** (no commits created by Entire) - Shadow branches: `entire/` created for checkpoints - Metadata: stored on both shadow branches and `entire/checkpoints/v1` branch (condensed on user commits) @@ -153,15 +152,6 @@ go build -o /tmp/entire-bin ./cmd/entire && \ - Preserves untracked files that existed at session start - AllowsMainBranch: **true** (safe on main/master) -### Auto-Commit Strategy (alias: dual) -- Active branch commits: **clean commits** with only `Entire-Checkpoint: <12-hex-char>` trailer -- Shadow branches: none -- Metadata: stored on orphan `entire/checkpoints/v1` branch at sharded paths -- Rewind: full reset allowed if commit is only on current branch - - Uses `git reset --hard` which doesn't delete untracked files - - **No preview warnings** (untracked files are safe) -- AllowsMainBranch: **false** (creates commits on active branch) - ## Additional Testing (Optional) ### Test Subagent Checkpoints diff --git a/.claude/skills/test-repo/test-harness.sh b/.claude/skills/test-repo/test-harness.sh index 3e257fac1..cbd5e4686 100755 --- a/.claude/skills/test-repo/test-harness.sh +++ b/.claude/skills/test-repo/test-harness.sh @@ -130,7 +130,7 @@ verify-shadow-branch) if git branch -a | grep -E "entire/[0-9a-f]"; then echo "✓ Shadow branch exists" else - echo "Note: No shadow branch (expected for auto-commit strategy)" + echo "Note: No shadow branch" fi ;; diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5a3e21afd..eba2bf4a8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -67,8 +67,6 @@ body: description: "Which strategy is configured? (check `.entire/settings.json` or `entire status`)" options: - manual-commit (default) - - auto-commit - - Not sure validations: required: true diff --git a/CLAUDE.md b/CLAUDE.md index dfdac7b2f..bc79bacc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,8 +289,7 @@ All strategies implement: | Strategy | Main Branch | Metadata Storage | Use Case | |----------|-------------|------------------|----------| -| **manual-commit** (default) | Unchanged (no commits) | `entire/-` branches + `entire/checkpoints/v1` | Recommended for most workflows | -| **auto-commit** | Creates clean commits | Orphan `entire/checkpoints/v1` branch | Teams that want code commits from sessions | +| **manual-commit** (default) | Unchanged (no commits) | `entire/-` branches + `entire/checkpoints/v1` | Session management without modifying active branch | #### Strategy Details @@ -309,16 +308,6 @@ All strategies implement: - PrePush hook can push `entire/checkpoints/v1` branch alongside user pushes - `AllowsMainBranch() = true` - safe to use on main/master since it never modifies commit history -**Auto-Commit Strategy** (`auto_commit.go`) -- Code commits to active branch with **clean history** (commits have `Entire-Checkpoint` trailer only) -- Metadata stored on orphan `entire/checkpoints/v1` branch at sharded paths: `//` -- Uses `checkpoint.WriteCommitted()` for metadata storage -- Checkpoint ID (12-hex-char) links code commits to metadata on `entire/checkpoints/v1` -- Full rewind allowed if commit is only on current branch (not in main); otherwise logs-only -- Rewind via `git reset --hard` -- PrePush hook can push `entire/checkpoints/v1` branch alongside user pushes -- `AllowsMainBranch() = true` - creates commits on active branch, safe to use on main/master - #### Key Files - `strategy.go` - Interface definition and context structs (`StepContext`, `TaskStepContext`, `RewindPoint`, etc.) @@ -336,7 +325,6 @@ All strategies implement: - `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, post-commit, pre-push) - `manual_commit_reset.go` - Shadow branch reset/cleanup functionality - `session_state.go` - Package-level session state functions (`LoadSessionState`, `SaveSessionState`, `ListSessionStates`, `FindMostRecentSession`) -- `auto_commit.go` - Auto-commit strategy implementation - `hooks.go` - Git hook installation #### Checkpoint Package (`cmd/entire/cli/checkpoint/`) @@ -434,10 +422,9 @@ Both strategies use a **12-hex-char random checkpoint ID** (e.g., `a3b2c4d5e6f7` **How checkpoint IDs work:** -1. **Generated once per checkpoint**: Either when saving (auto-commit) or when condensing (manual-commit) +1. **Generated once per checkpoint**: When condensing session metadata to the metadata branch 2. **Added to user commits** via `Entire-Checkpoint` trailer: - - **Auto-commit**: Added programmatically when creating the commit - **Manual-commit**: Added via `prepare-commit-msg` hook (user can remove it before committing) 3. **Used for directory sharding** on `entire/checkpoints/v1` branch: @@ -502,12 +489,12 @@ Commit subject: `Checkpoint: ` (or custom subject for task checkp Trailers: - `Entire-Session: ` - Session identifier -- `Entire-Strategy: ` - Strategy name (manual-commit or auto-commit) +- `Entire-Strategy: ` - Strategy name (manual-commit) - `Entire-Agent: ` - Agent name (optional, e.g., "Claude Code") -- `Ephemeral-branch: ` - Shadow branch name (optional, manual-commit only) +- `Ephemeral-branch: ` - Shadow branch name (optional) - `Entire-Metadata-Task: ` - Task metadata path (optional, for task checkpoints) -**Note:** Both strategies keep active branch history **clean** - the only addition to user commits is the single `Entire-Checkpoint` trailer. Manual-commit never creates commits on the active branch (user creates them manually). Auto-commit creates commits but only adds the checkpoint trailer. All detailed session data (transcripts, prompts, context) is stored on the `entire/checkpoints/v1` orphan branch or shadow branches. +**Note:** Manual-commit keeps active branch history clean - the only addition to user commits is the single `Entire-Checkpoint` trailer. Manual-commit never creates commits on the active branch (user creates them manually). All detailed session data (transcripts, prompts, context) is stored on the `entire/checkpoints/v1` orphan branch or shadow branches. #### Multi-Session Behavior diff --git a/README.md b/README.md index 641195cd1..aafd30c9e 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ Entire hooks into your Git workflow to capture AI agent sessions as you work. Se With Entire, you can: -* **Understand why code changed** — see the full prompt/response transcript and files touched -* **Recover instantly** — rewind to a known-good checkpoint when an agent goes sideways and resume seamlessly -* **Keep Git history clean** — preserve agent context on a separate branch -* **Onboard faster** — show the path from prompt → change → commit -* **Maintain traceability** — support audit and compliance requirements when needed +- **Understand why code changed** — see the full prompt/response transcript and files touched +- **Recover instantly** — rewind to a known-good checkpoint when an agent goes sideways and resume seamlessly +- **Keep Git history clean** — preserve agent context on a separate branch +- **Onboard faster** — show the path from prompt → change → commit +- **Maintain traceability** — support audit and compliance requirements when needed ## Table of Contents @@ -61,6 +61,7 @@ This installs agent and git hooks to work with your AI agent (Claude Code, Gemin The hooks capture session data at specific points in your workflow. Your code commits stay clean—all session metadata is stored on a separate `entire/checkpoints/v1` branch. **When checkpoints are created** depends on your chosen strategy (default is `manual-commit`): + - **Manual-commit**: Checkpoints are created when you or the agent make a git commit - **Auto-commit**: Checkpoints are created after each agent response @@ -131,7 +132,7 @@ Your Branch entire/checkpoints/v1 │ │ │ ┌─── Agent works ───┐ │ │ │ Step 1 │ │ - │ │ Step 2 │ │ + │ │ Step 2 │ │ │ │ Step 3 │ │ │ └───────────────────┘ │ │ │ @@ -165,37 +166,33 @@ Multiple AI sessions can run on the same commit. If you start a second session w ## Commands Reference -| Command | Description | -| ---------------- | ----------------------------------------------------------------------------- | -| `entire clean` | Clean up orphaned Entire data | -| `entire disable` | Remove Entire hooks from repository | -| `entire doctor` | Fix or clean up stuck sessions | -| `entire enable` | Enable Entire in your repository (uses `manual-commit` by default) | -| `entire explain` | Explain a session or commit | -| `entire reset` | Delete the shadow branch and session state for the current HEAD commit | +| Command | Description | +| ---------------- | ------------------------------------------------------------------------------------------------- | +| `entire clean` | Clean up orphaned Entire data | +| `entire disable` | Remove Entire hooks from repository | +| `entire doctor` | Fix or clean up stuck sessions | +| `entire enable` | Enable Entire in your repository (uses `manual-commit` by default) | +| `entire explain` | Explain a session or commit | +| `entire reset` | Delete the shadow branch and session state for the current HEAD commit | | `entire resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) to continue | -| `entire rewind` | Rewind to a previous checkpoint | -| `entire status` | Show current session and strategy info | -| `entire version` | Show Entire CLI version | +| `entire rewind` | Rewind to a previous checkpoint | +| `entire status` | Show current session and strategy info | +| `entire version` | Show Entire CLI version | ### `entire enable` Flags -| Flag | Description | -|------------------------|--------------------------------------------------------------------| +| Flag | Description | +| ---------------------- | --------------------------------------------------------------------- | | `--agent ` | AI agent to install hooks for: `claude-code`, `gemini`, or `opencode` | -| `--force`, `-f` | Force reinstall hooks (removes existing Entire hooks first) | -| `--local` | Write settings to `settings.local.json` instead of `settings.json` | -| `--project` | Write settings to `settings.json` even if it already exists | -| `--skip-push-sessions` | Disable automatic pushing of session logs on git push | -| `--strategy ` | Strategy to use: `manual-commit` (default) or `auto-commit` | -| `--telemetry=false` | Disable anonymous usage analytics | +| `--force`, `-f` | Force reinstall hooks (removes existing Entire hooks first) | +| `--local` | Write settings to `settings.local.json` instead of `settings.json` | +| `--project` | Write settings to `settings.json` even if it already exists | +| `--skip-push-sessions` | Disable automatic pushing of session logs on git push | +| `--telemetry=false` | Disable anonymous usage analytics | **Examples:** ``` -# Use auto-commit strategy -entire enable --strategy auto-commit - # Force reinstall hooks entire enable --force @@ -232,10 +229,10 @@ Personal overrides, gitignored by default: ### Configuration Options | Option | Values | Description | -|--------------------------------------|----------------------------------|------------------------------------------------------| +| ------------------------------------ | -------------------------------- | ---------------------------------------------------- | | `enabled` | `true`, `false` | Enable/disable Entire | | `log_level` | `debug`, `info`, `warn`, `error` | Logging verbosity | -| `strategy` | `manual-commit`, `auto-commit` | Session capture strategy | +| `strategy` | `manual-commit` | Session capture strategy | | `strategy_options.push_sessions` | `true`, `false` | Auto-push `entire/checkpoints/v1` branch on git push | | `strategy_options.summarize.enabled` | `true`, `false` | Auto-generate AI summaries at commit time | | `telemetry` | `true`, `false` | Send anonymous usage statistics to Posthog | @@ -244,11 +241,11 @@ Personal overrides, gitignored by default: Each agent stores its hook configuration in its own directory. When you run `entire enable`, hooks are installed in the appropriate location for each selected agent: -| Agent | Hook Location | Format | -|-------|--------------|--------| -| Claude Code | `.claude/settings.json` | JSON hooks config | -| Gemini CLI | `.gemini/settings.json` | JSON hooks config | -| OpenCode | `.opencode/plugins/entire.ts` | TypeScript plugin | +| Agent | Hook Location | Format | +| ----------- | ----------------------------- | ----------------- | +| Claude Code | `.claude/settings.json` | JSON hooks config | +| Gemini CLI | `.gemini/settings.json` | JSON hooks config | +| OpenCode | `.opencode/plugins/entire.ts` | TypeScript plugin | You can enable multiple agents at the same time — each agent's hooks are independent. Entire detects which agents are active by checking for installed hooks, not by a setting in `settings.json`. @@ -267,6 +264,7 @@ When enabled, Entire automatically generates AI summaries for checkpoints at com ``` **Requirements:** + - Claude CLI must be installed and authenticated (`claude` command available in PATH) - Summary generation is non-blocking: failures are logged but don't prevent commits @@ -316,12 +314,12 @@ Entire automatically redacts detected secrets (API keys, tokens, credentials) wh ### Common Issues -| Issue | Solution | -|--------------------------|-------------------------------------------------------------------------------------------| -| "Not a git repository" | Navigate to a Git repository first | -| "Entire is disabled" | Run `entire enable` | -| "No rewind points found" | Work with your configured agent and commit (manual-commit) or wait for an agent response (auto-commit) | -| "shadow branch conflict" | Run `entire reset --force` | +| Issue | Solution | +| ------------------------ | ------------------------------------------------------- | +| "Not a git repository" | Navigate to a Git repository first | +| "Entire is disabled" | Run `entire enable` | +| "No rewind points found" | Work with your configured agent and commit your changes | +| "shadow branch conflict" | Run `entire reset --force` | ### SSH Authentication Errors diff --git a/cmd/entire/cli/checkpoint/checkpoint.go b/cmd/entire/cli/checkpoint/checkpoint.go index aaed64c55..187ed1b9e 100644 --- a/cmd/entire/cli/checkpoint/checkpoint.go +++ b/cmd/entire/cli/checkpoint/checkpoint.go @@ -239,7 +239,7 @@ type WriteCommittedOptions struct { // This is useful for copying task metadata files, subagent transcripts, etc. MetadataDir string - // Task checkpoint fields (for auto-commit strategy task checkpoints) + // Task checkpoint fields (for task/subagent checkpoints) IsTask bool // Whether this is a task checkpoint ToolUseID string // Tool use ID for task checkpoints diff --git a/cmd/entire/cli/clean.go b/cmd/entire/cli/clean.go index 29e1e70f1..15ae93499 100644 --- a/cmd/entire/cli/clean.go +++ b/cmd/entire/cli/clean.go @@ -32,9 +32,7 @@ This command finds and removes orphaned data from any strategy: reference them. Checkpoint metadata (entire/checkpoints/v1 branch) - For auto-commit checkpoints: orphaned when commits are rebased/squashed - and no commit references the checkpoint ID anymore. - Manual-commit checkpoints are permanent (condensed history) and are + Checkpoints are permanent (condensed session history) and are never considered orphaned. Temporary files (.entire/tmp/) diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index 6eb704cbb..1fea38308 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -1,13 +1,10 @@ package cli import ( - "context" "fmt" - "log/slog" "strings" "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" @@ -67,24 +64,7 @@ func IsEnabled() (bool, error) { // func GetStrategy() strategy.Strategy { - s, err := settings.Load() - if err != nil { - // Fall back to default on error - logging.Info(context.Background(), "falling back to default strategy - failed to load settings", - slog.String("error", err.Error())) - return strategy.Default() - } - - strat, err := strategy.Get(s.Strategy) - if err != nil { - // Fall back to default if strategy not found - logging.Info(context.Background(), "falling back to default strategy - configured strategy not found", - slog.String("configured", s.Strategy), - slog.String("error", err.Error())) - return strategy.Default() - } - - return strat + return strategy.NewManualCommitStrategy() } // GetLogLevel returns the configured log level from settings. diff --git a/cmd/entire/cli/config_test.go b/cmd/entire/cli/config_test.go index 5efaa5933..7debf43bc 100644 --- a/cmd/entire/cli/config_test.go +++ b/cmd/entire/cli/config_test.go @@ -6,13 +6,11 @@ import ( "strings" "testing" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) const ( - testSettingsStrategy = `{"strategy": "manual-commit"}` - testSettingsEnabled = `{"strategy": "manual-commit", "enabled": true}` - testSettingsDisabled = `{"strategy": "manual-commit", "enabled": false}` + testSettingsEnabled = `{"enabled": true}` + testSettingsDisabled = `{"enabled": false}` ) func TestLoadEntireSettings_EnabledDefaultsToTrue(t *testing.T) { @@ -34,7 +32,7 @@ func TestLoadEntireSettings_EnabledDefaultsToTrue(t *testing.T) { if err := os.MkdirAll(settingsDir, 0o755); err != nil { t.Fatalf("Failed to create settings dir: %v", err) } - settingsContent := testSettingsStrategy + settingsContent := `{}` if err := os.WriteFile(EntireSettingsFile, []byte(settingsContent), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -82,8 +80,7 @@ func TestSaveEntireSettings_PreservesEnabled(t *testing.T) { // Save settings with Enabled = false settings := &EntireSettings{ - Strategy: "manual-commit", - Enabled: false, + Enabled: false, } if err := SaveEntireSettings(settings); err != nil { t.Fatalf("SaveEntireSettings() error = %v", err) @@ -165,7 +162,7 @@ func TestLoadEntireSettings_LocalOverridesStrategy(t *testing.T) { t.Fatalf("Failed to write settings file: %v", err) } - localSettings := `{"strategy": "` + strategy.StrategyNameAutoCommit + `"}` + localSettings := `{"enabled": true}` if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -174,9 +171,6 @@ func TestLoadEntireSettings_LocalOverridesStrategy(t *testing.T) { if err != nil { t.Fatalf("LoadEntireSettings() error = %v", err) } - if settings.Strategy != strategy.StrategyNameAutoCommit { - t.Errorf("Strategy should be 'auto-commit' from local override, got %q", settings.Strategy) - } if !settings.Enabled { t.Error("Enabled should remain true from base settings") } @@ -202,15 +196,12 @@ func TestLoadEntireSettings_LocalOverridesEnabled(t *testing.T) { if settings.Enabled { t.Error("Enabled should be false from local override") } - if settings.Strategy != strategy.StrategyNameManualCommit { - t.Errorf("Strategy should remain 'manual-commit' from base settings, got %q", settings.Strategy) - } } func TestLoadEntireSettings_LocalOverridesLocalDev(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := testSettingsStrategy + baseSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -232,7 +223,7 @@ func TestLoadEntireSettings_LocalOverridesLocalDev(t *testing.T) { func TestLoadEntireSettings_LocalMergesStrategyOptions(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := `{"strategy": "manual-commit", "strategy_options": {"key1": "value1", "key2": "value2"}}` + baseSettings := `{"enabled": true, "strategy_options": {"key1": "value1", "key2": "value2"}}` if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -262,7 +253,7 @@ func TestLoadEntireSettings_OnlyLocalFileExists(t *testing.T) { setupLocalOverrideTestDir(t) // No base settings file - localSettings := `{"strategy": "auto-commit"}` + localSettings := `{"enabled": true}` if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -271,64 +262,6 @@ func TestLoadEntireSettings_OnlyLocalFileExists(t *testing.T) { if err != nil { t.Fatalf("LoadEntireSettings() error = %v", err) } - if settings.Strategy != strategyDisplayAutoCommit { - t.Errorf("Strategy should be 'auto-commit' from local file, got %q", settings.Strategy) - } - if !settings.Enabled { - t.Error("Enabled should default to true") - } -} - -func TestLoadEntireSettings_NoLocalFileUsesBase(t *testing.T) { - setupLocalOverrideTestDir(t) - - baseSettings := `{"strategy": "manual-commit", "enabled": true}` - if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { - t.Fatalf("Failed to write settings file: %v", err) - } - - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - if settings.Strategy != "manual-commit" { - t.Errorf("Strategy should be 'shadow' from base settings, got %q", settings.Strategy) - } -} - -func TestLoadEntireSettings_EmptyStrategyInLocalDoesNotOverride(t *testing.T) { - setupLocalOverrideTestDir(t) - - baseSettings := testSettingsStrategy - if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { - t.Fatalf("Failed to write settings file: %v", err) - } - - localSettings := `{"strategy": ""}` - if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { - t.Fatalf("Failed to write local settings file: %v", err) - } - - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - if settings.Strategy != "manual-commit" { - t.Errorf("Strategy should remain 'shadow', got %q", settings.Strategy) - } -} - -func TestLoadEntireSettings_NeitherFileExistsReturnsDefaults(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - if settings.Strategy != strategy.DefaultStrategyName { - t.Errorf("Strategy should be default %q, got %q", strategy.DefaultStrategyName, settings.Strategy) - } if !settings.Enabled { t.Error("Enabled should default to true") } @@ -337,7 +270,7 @@ func TestLoadEntireSettings_NeitherFileExistsReturnsDefaults(t *testing.T) { func TestLoadEntireSettings_RejectsUnknownKeysInBase(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := `{"strategy": "manual-commit", "bogus_key": true}` + baseSettings := `{"bogus_key": true}` if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -354,7 +287,7 @@ func TestLoadEntireSettings_RejectsUnknownKeysInBase(t *testing.T) { func TestLoadEntireSettings_RejectsUnknownKeysInLocal(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := `{"strategy": "manual-commit"}` + baseSettings := `{}` if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } diff --git a/cmd/entire/cli/debug.go b/cmd/entire/cli/debug.go deleted file mode 100644 index 1c5a9c9e2..000000000 --- a/cmd/entire/cli/debug.go +++ /dev/null @@ -1,377 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "os" - "sort" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" - "github.com/entireio/cli/cmd/entire/cli/transcript" - - "github.com/go-git/go-git/v5" - "github.com/spf13/cobra" -) - -func newDebugCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "debug", - Short: "Debug commands for troubleshooting", - Hidden: true, // Hidden from help output - RunE: func(cmd *cobra.Command, _ []string) error { - return cmd.Help() - }, - } - - cmd.AddCommand(newDebugAutoCommitCmd()) - - return cmd -} - -func newDebugAutoCommitCmd() *cobra.Command { - var transcriptPath string - - cmd := &cobra.Command{ - Use: "auto-commit", - Short: "Show whether current state would trigger an auto-commit", - Long: `Analyzes the current session state and configuration to determine -if the Stop hook would create an auto-commit. - -This simulates what the Stop hook checks: -- Current session and pre-prompt state -- Modified files from transcript (if --transcript provided) -- New files (current untracked - pre-prompt untracked) -- Deleted files (tracked files that were removed) - -Without --transcript, shows git status changes instead.`, - RunE: func(cmd *cobra.Command, _ []string) error { - return runDebugAutoCommit(cmd.OutOrStdout(), transcriptPath) - }, - } - - cmd.Flags().StringVarP(&transcriptPath, "transcript", "t", "", "Path to transcript file (.jsonl) to parse for modified files") - - return cmd -} - -func runDebugAutoCommit(w io.Writer, transcriptPath string) error { - // Check if we're in a git repository - repoRoot, err := paths.WorktreeRoot() - if err != nil { - fmt.Fprintln(w, "Not in a git repository") - return nil //nolint:nilerr // not being in a git repo is expected, not an error for status check - } - fmt.Fprintf(w, "Repository: %s\n\n", repoRoot) - - // Print strategy info - strat := GetStrategy() - isAutoCommit := strat.Name() == strategy.StrategyNameAutoCommit - printStrategyInfo(w, strat, isAutoCommit) - - // Print session state - currentSession := printSessionState(w) - - // Auto-detect transcript if not provided - if transcriptPath == "" && currentSession != "" { - detected, detectErr := findTranscriptForSession(currentSession) - if detectErr != nil { - fmt.Fprintf(w, "\nCould not auto-detect transcript: %v\n", detectErr) - } else if detected != "" { - transcriptPath = detected - fmt.Fprintf(w, "\nAuto-detected transcript: %s\n", transcriptPath) - } - } - - // Print file changes and get total - fmt.Fprintln(w, "\n=== File Changes ===") - var totalChanges int - if transcriptPath != "" { - totalChanges = printTranscriptChanges(w, transcriptPath, currentSession, repoRoot) - } else { - var err error - totalChanges, err = printGitStatusChanges(w) - if err != nil { - return err - } - } - - // Print decision - printDecision(w, isAutoCommit, strat.Name(), totalChanges) - - // Print transcript location help if we couldn't find one - if transcriptPath == "" { - printTranscriptHelp(w) - } - - return nil -} - -func printStrategyInfo(w io.Writer, strat strategy.Strategy, isAutoCommit bool) { - fmt.Fprintf(w, "Strategy: %s\n", strat.Name()) - fmt.Fprintf(w, "Auto-commit strategy: %v\n", isAutoCommit) - - _, branchName, err := IsOnDefaultBranch() - if err != nil { - fmt.Fprintf(w, "Branch: (unable to determine: %v)\n\n", err) - } else { - fmt.Fprintf(w, "Branch: %s\n\n", branchName) - } -} - -func printSessionState(w io.Writer) string { - fmt.Fprintln(w, "=== Session State ===") - - currentSession := strategy.FindMostRecentSession() - if currentSession == "" { - fmt.Fprintln(w, "Current session: (none - no active session)") - return "" - } - - fmt.Fprintf(w, "Current session: %s\n", currentSession) - printPrePromptState(w, currentSession) - return currentSession -} - -func printPrePromptState(w io.Writer, sessionID string) { - preState, err := LoadPrePromptState(sessionID) - switch { - case err != nil: - fmt.Fprintf(w, "Pre-prompt state: (error: %v)\n", err) - case preState != nil: - fmt.Fprintf(w, "Pre-prompt state: captured at %s\n", preState.Timestamp) - fmt.Fprintf(w, " Pre-existing untracked files: %d\n", len(preState.UntrackedFiles)) - printUntrackedFilesSummary(w, preState.UntrackedFiles) - default: - fmt.Fprintln(w, "Pre-prompt state: (none captured)") - } -} - -func printUntrackedFilesSummary(w io.Writer, files []string) { - if len(files) == 0 { - return - } - if len(files) <= 10 { - for _, f := range files { - fmt.Fprintf(w, " - %s\n", f) - } - } else { - for _, f := range files[:5] { - fmt.Fprintf(w, " - %s\n", f) - } - fmt.Fprintf(w, " ... and %d more\n", len(files)-5) - } -} - -func printTranscriptChanges(w io.Writer, transcriptPath, currentSession, repoRoot string) int { - fmt.Fprintf(w, "\nParsing transcript: %s\n", transcriptPath) - - var modifiedFromTranscript, newFiles, deletedFiles []string - - // Parse transcript - parsed, _, parseErr := transcript.ParseFromFileAtLine(transcriptPath, 0) - if parseErr != nil { - fmt.Fprintf(w, " Error parsing transcript: %v\n", parseErr) - } else { - modifiedFromTranscript = extractModifiedFiles(parsed) - fmt.Fprintf(w, " Found %d modified files in transcript\n", len(modifiedFromTranscript)) - } - // Compute new and deleted files (single git status call) - // Load preState only if we have an active session (needed for new file detection) - var preState *PrePromptState - if currentSession != "" { - var loadErr error - preState, loadErr = LoadPrePromptState(currentSession) - if loadErr != nil { - fmt.Fprintf(w, " Error loading pre-prompt state: %v\n", loadErr) - } - } - // Always call DetectFileChanges - deleted files don't depend on preState - fileChanges, err := DetectFileChanges(preState.PreUntrackedFiles()) - if err != nil { - fmt.Fprintf(w, " Error computing file changes: %v\n", err) - } - if fileChanges != nil { - newFiles = fileChanges.New - deletedFiles = fileChanges.Deleted - } - - // Filter and normalize paths - modifiedFromTranscript = FilterAndNormalizePaths(modifiedFromTranscript, repoRoot) - newFiles = FilterAndNormalizePaths(newFiles, repoRoot) - deletedFiles = FilterAndNormalizePaths(deletedFiles, repoRoot) - - // Print files - printFileList(w, "Modified (from transcript)", "M", modifiedFromTranscript) - printFileList(w, "New files (created during session)", "+", newFiles) - printFileList(w, "Deleted files", "D", deletedFiles) - - totalChanges := len(modifiedFromTranscript) + len(newFiles) + len(deletedFiles) - if totalChanges == 0 { - fmt.Fprintln(w, "\nNo changes detected from transcript") - } - - return totalChanges -} - -func printGitStatusChanges(w io.Writer) (int, error) { - fmt.Fprintln(w, "\n(No --transcript provided, showing git status instead)") - fmt.Fprintln(w, "Note: Stop hook uses transcript parsing, not git status") - - modifiedFiles, untrackedFiles, deletedFiles, stagedFiles, err := getFileChanges() - if err != nil { - return 0, fmt.Errorf("failed to get file changes: %w", err) - } - - printFileList(w, "Staged files", "+", stagedFiles) - printFileList(w, "Modified files", "M", modifiedFiles) - printFileList(w, "Untracked files", "?", untrackedFiles) - printFileList(w, "Deleted files", "D", deletedFiles) - - totalChanges := len(modifiedFiles) + len(untrackedFiles) + len(deletedFiles) + len(stagedFiles) - if totalChanges == 0 { - fmt.Fprintln(w, "\nNo changes detected in git status") - } - - return totalChanges, nil -} - -func printFileList(w io.Writer, label, prefix string, files []string) { - if len(files) == 0 { - return - } - fmt.Fprintf(w, "\n%s (%d):\n", label, len(files)) - for _, f := range files { - fmt.Fprintf(w, " %s %s\n", prefix, f) - } -} - -func printDecision(w io.Writer, isAutoCommit bool, stratName string, totalChanges int) { - fmt.Fprintln(w, "\n=== Auto-Commit Decision ===") - - wouldCommit := isAutoCommit && totalChanges > 0 - - if wouldCommit { - fmt.Fprintln(w, "Result: YES - Auto-commit would be triggered") - fmt.Fprintf(w, " %d file(s) would be committed\n", totalChanges) - return - } - - fmt.Fprintln(w, "Result: NO - Auto-commit would NOT be triggered") - fmt.Fprintln(w, "Reasons:") - if !isAutoCommit { - fmt.Fprintf(w, " - Strategy is not auto-commit (using %s)\n", stratName) - } - if totalChanges == 0 { - fmt.Fprintln(w, " - No file changes to commit") - } -} - -func printTranscriptHelp(w io.Writer) { - fmt.Fprintln(w, "\n=== Finding Transcript ===") - fmt.Fprintln(w, "Claude Code transcripts are typically at:") - homeDir, err := os.UserHomeDir() - if err != nil { - fmt.Fprintln(w, " ~/.claude/projects/*/sessions/*.jsonl") - } else { - fmt.Fprintf(w, " %s/.claude/projects/*/sessions/*.jsonl\n", homeDir) - } -} - -// getFileChanges returns the current file changes from git status. -// Returns (modifiedFiles, untrackedFiles, deletedFiles, stagedFiles, error) -func getFileChanges() ([]string, []string, []string, []string, error) { - repo, err := openRepository() - if err != nil { - return nil, nil, nil, nil, err - } - - worktree, err := repo.Worktree() - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("getting worktree: %w", err) - } - - status, err := worktree.Status() - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("getting status: %w", err) - } - - var modifiedFiles, untrackedFiles, deletedFiles, stagedFiles []string - - for file, st := range status { - // Skip .entire directory - if paths.IsInfrastructurePath(file) { - continue - } - - // Check staging area first - switch st.Staging { - case git.Added, git.Modified: - stagedFiles = append(stagedFiles, file) - continue - case git.Deleted: - deletedFiles = append(deletedFiles, file) - continue - case git.Unmodified, git.Renamed, git.Copied, git.UpdatedButUnmerged, git.Untracked: - // Fall through to check worktree status - } - - // Check worktree status - switch st.Worktree { - case git.Modified: - modifiedFiles = append(modifiedFiles, file) - case git.Untracked: - untrackedFiles = append(untrackedFiles, file) - case git.Deleted: - deletedFiles = append(deletedFiles, file) - case git.Unmodified, git.Added, git.Renamed, git.Copied, git.UpdatedButUnmerged: - // No action needed - } - } - - // Sort for consistent output - sort.Strings(modifiedFiles) - sort.Strings(untrackedFiles) - sort.Strings(deletedFiles) - sort.Strings(stagedFiles) - - return modifiedFiles, untrackedFiles, deletedFiles, stagedFiles, nil -} - -// findTranscriptForSession attempts to find the transcript file for a session. -// Returns the path if found, empty string if not found, or error on failure. -func findTranscriptForSession(sessionID string) (string, error) { - // Try to get agent type from session state - sessionState, err := strategy.LoadSessionState(sessionID) - if err != nil { - return "", fmt.Errorf("failed to load session state: %w", err) - } - - var ag agent.Agent - if sessionState != nil && sessionState.AgentType != "" { - ag, err = agent.GetByAgentType(sessionState.AgentType) - if err != nil { - return "", fmt.Errorf("failed to get agent for type %q: %w", sessionState.AgentType, err) - } - } else { - return "", fmt.Errorf("failed to get agent from sessionID: %s", sessionID) - } - - // Resolve transcript path (checks session state's transcript_path first, - // falls back to agent's GetSessionDir + ResolveSessionFile) - transcriptPath, err := resolveTranscriptPath(sessionID, ag) - if err != nil { - return "", fmt.Errorf("failed to resolve transcript path: %w", err) - } - - // Check if it exists - if _, err := os.Stat(transcriptPath); err != nil { - if os.IsNotExist(err) { - return "", nil // Not found, but not an error - } - return "", fmt.Errorf("failed to stat transcript: %w", err) - } - - return transcriptPath, nil -} diff --git a/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go index 9442de7be..a3d68d937 100644 --- a/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go +++ b/cmd/entire/cli/e2e_test/scenario_agent_commit_test.go @@ -13,7 +13,7 @@ import ( func TestE2E_AgentCommitsDuringTurn(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. First, agent creates a file t.Log("Step 1: Agent creating file") @@ -72,7 +72,7 @@ Only run these two commands, nothing else.` func TestE2E_MultipleAgentSessions(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Session 1: Create hello.go t.Log("Session 1: Creating hello.go") diff --git a/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go index 4feecdde9..111a6b0d4 100644 --- a/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go +++ b/cmd/entire/cli/e2e_test/scenario_basic_workflow_test.go @@ -14,7 +14,7 @@ import ( func TestE2E_BasicWorkflow(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. Agent creates a file t.Log("Step 1: Running agent to create hello.go") @@ -57,7 +57,7 @@ func TestE2E_BasicWorkflow(t *testing.T) { func TestE2E_MultipleChanges(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. First agent action: create hello.go t.Log("Step 1: Creating first file") diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go index 7dde8462f..fcb4ce819 100644 --- a/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_test.go @@ -13,7 +13,7 @@ import ( func TestE2E_CheckpointMetadata(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. Agent creates a file t.Log("Step 1: Agent creating file") @@ -62,7 +62,7 @@ func TestE2E_CheckpointMetadata(t *testing.T) { func TestE2E_CheckpointIDFormat(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. Agent makes changes result, err := env.RunAgent(PromptCreateHelloGo.Prompt) @@ -86,55 +86,3 @@ func TestE2E_CheckpointIDFormat(t *testing.T) { "Checkpoint ID should be lowercase hex: got %c", c) } } - -// TestE2E_AutoCommitStrategy tests the auto-commit strategy creates clean commits. -func TestE2E_AutoCommitStrategy(t *testing.T) { - t.Parallel() - - env := NewFeatureBranchEnv(t, "auto-commit") - - // Count commits before agent action - commitsBefore := env.GetCommitCount() - t.Logf("Commits before: %d", commitsBefore) - - // 1. Agent creates a file - t.Log("Step 1: Agent creating file with auto-commit strategy") - result, err := env.RunAgent(PromptCreateHelloGo.Prompt) - require.NoError(t, err) - AssertAgentSuccess(t, result, err) - - // 2. Verify file exists - require.True(t, env.FileExists("hello.go"), "hello.go should exist") - AssertHelloWorldProgram(t, env, "hello.go") - - // 3. With auto-commit, commits are created automatically - commitsAfter := env.GetCommitCount() - t.Logf("Commits after: %d", commitsAfter) - assert.Greater(t, commitsAfter, commitsBefore, "Auto-commit should create at least one commit") - - // 4. Verify checkpoint trailer in commit history - checkpointID, err := env.GetLatestCheckpointIDFromHistory() - require.NoError(t, err, "Should find checkpoint ID in commit history") - require.NotEmpty(t, checkpointID, "Commit should have Entire-Checkpoint trailer") - t.Logf("Checkpoint ID: %s", checkpointID) - - // Verify checkpoint ID format (12 hex characters) - assert.Len(t, checkpointID, 12, "Checkpoint ID should be 12 characters") - - // 5. Verify metadata branch exists - assert.True(t, env.BranchExists("entire/checkpoints/v1"), - "entire/checkpoints/v1 branch should exist") - - // 6. Check for rewind points - points := env.GetRewindPoints() - assert.GreaterOrEqual(t, len(points), 1, "Should have at least 1 rewind point") - t.Logf("Found %d rewind points", len(points)) - - // 7. Validate checkpoint has proper metadata on entire/checkpoints/v1 - env.ValidateCheckpoint(CheckpointValidation{ - CheckpointID: checkpointID, - Strategy: "auto-commit", - FilesTouched: []string{"hello.go"}, - ExpectedTranscriptContent: []string{"hello.go"}, - }) -} diff --git a/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go index bf03d6aaf..b0ee2c816 100644 --- a/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go +++ b/cmd/entire/cli/e2e_test/scenario_checkpoint_workflows_test.go @@ -23,7 +23,7 @@ import ( func TestE2E_Scenario3_MultipleGranularCommits(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Count commits before commitsBefore := env.GetCommitCount() @@ -123,7 +123,7 @@ Do each task in order, making the commit after each file creation.` func TestE2E_Scenario4_UserSplitsCommits(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Agent creates multiple files in one prompt multiFilePrompt := `Create these files: @@ -224,7 +224,7 @@ Create all four files, no other files or actions.` func TestE2E_Scenario5_PartialCommitStashNextPrompt(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Prompt 1: Agent creates files A, B, C t.Log("Prompt 1: Creating files A, B, C") @@ -321,7 +321,7 @@ Create both files, nothing else.` func TestE2E_Scenario6_StashSecondPromptUnstashCommitAll(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Prompt 1: Agent creates files A, B, C t.Log("Prompt 1: Creating files A, B, C") @@ -430,7 +430,7 @@ Create both files, nothing else.` func TestE2E_Scenario7_PartialStagingSimulated(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Create partial.go as an existing tracked file first. // For MODIFIED files (vs NEW files), content-aware detection always @@ -548,7 +548,7 @@ func Second() int { func TestE2E_ContentAwareOverlap_RevertAndReplace(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Agent creates a file t.Log("Agent creating file with specific content") @@ -624,7 +624,7 @@ func CompletelyDifferent() string { func TestE2E_Scenario1_BasicFlow(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. User submits prompt (triggers UserPromptSubmit hook → InitializeSession) t.Log("Step 1: User submits prompt") @@ -680,7 +680,7 @@ Create only this file.` func TestE2E_Scenario2_AgentCommitsDuringTurn(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) commitsBefore := env.GetCommitCount() @@ -740,7 +740,7 @@ Create the file first, then run the git commands.` func TestE2E_ExistingFiles_ModifyAndCommit(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Create and commit an existing file first env.WriteFile("config.go", `package main @@ -782,7 +782,7 @@ Keep the existing content and just add the new key. Only modify this one file.` func TestE2E_ExistingFiles_StashModifications(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Create and commit two existing files env.WriteFile("fileA.go", "package main\n\nfunc A() { /* original */ }\n") @@ -842,7 +842,7 @@ Only modify these two files.` func TestE2E_ExistingFiles_SplitCommits(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Create and commit multiple existing files env.WriteFile("model.go", "package main\n\ntype Model struct{}\n") @@ -923,7 +923,7 @@ Only modify these three files.` func TestE2E_ExistingFiles_RevertModification(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Create and commit an existing file originalContent := `package main @@ -983,7 +983,7 @@ func UserAdd(x, y int) int { func TestE2E_ExistingFiles_MixedNewAndModified(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Create and commit an existing file env.WriteFile("main.go", `package main @@ -1034,7 +1034,7 @@ Complete all three tasks.` func TestE2E_EndedSession_UserCommitsAfterExit(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Agent creates files A, B, C — session ends when agent exits prompt := `Create these files: @@ -1096,7 +1096,7 @@ Create all three files, nothing else.` func TestE2E_DeletedFiles_CommitDeletion(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Pre-commit a file that will be deleted env.WriteFile("to_delete.go", "package main\n\nfunc ToDelete() {}\n") @@ -1158,7 +1158,7 @@ Do both tasks.` func TestE2E_AgentCommitsMidTurn_UserCommitsRemainder(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) commitsBefore := env.GetCommitCount() @@ -1229,7 +1229,7 @@ Do all tasks in order. Create each file, then commit the first two, then create func TestE2E_TrailerRemoval_SkipsCondensation(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Agent creates a file prompt := `Create a file called trailer_test.go with content: @@ -1265,7 +1265,7 @@ Create only this file.` func TestE2E_SessionDepleted_ManualEditNoCheckpoint(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Agent creates a file prompt := `Create a file called depleted.go with content: diff --git a/cmd/entire/cli/e2e_test/scenario_rewind_test.go b/cmd/entire/cli/e2e_test/scenario_rewind_test.go index 990a0bcd1..6534f232c 100644 --- a/cmd/entire/cli/e2e_test/scenario_rewind_test.go +++ b/cmd/entire/cli/e2e_test/scenario_rewind_test.go @@ -13,7 +13,7 @@ import ( func TestE2E_RewindToCheckpoint(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. Agent creates first file t.Log("Step 1: Creating first file") @@ -69,7 +69,7 @@ func TestE2E_RewindToCheckpoint(t *testing.T) { func TestE2E_RewindAfterCommit(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. Agent creates file t.Log("Step 1: Creating file") @@ -131,7 +131,7 @@ func TestE2E_RewindAfterCommit(t *testing.T) { func TestE2E_RewindMultipleFiles(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. Agent creates multiple files t.Log("Step 1: Creating first file") diff --git a/cmd/entire/cli/e2e_test/scenario_subagent_test.go b/cmd/entire/cli/e2e_test/scenario_subagent_test.go index 3d481acc7..c4ac60972 100644 --- a/cmd/entire/cli/e2e_test/scenario_subagent_test.go +++ b/cmd/entire/cli/e2e_test/scenario_subagent_test.go @@ -27,7 +27,7 @@ func TestE2E_SubagentCheckpoint(t *testing.T) { t.Skipf("Skipping subagent test for %s (Task tool is Claude Code specific)", defaultAgent) } - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Get rewind points before agent action pointsBefore := env.GetRewindPoints() @@ -107,7 +107,7 @@ func TestE2E_SubagentCheckpoint(t *testing.T) { func TestE2E_SubagentCheckpoint_CommitFlow(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // 1. Run prompt that may trigger Task tool t.Log("Step 1: Running prompt that may use Task tool") diff --git a/cmd/entire/cli/e2e_test/testenv.go b/cmd/entire/cli/e2e_test/testenv.go index 5585783c9..3d27cf55a 100644 --- a/cmd/entire/cli/e2e_test/testenv.go +++ b/cmd/entire/cli/e2e_test/testenv.go @@ -67,7 +67,7 @@ func NewTestEnv(t *testing.T) *TestEnv { // NewFeatureBranchEnv creates an E2E test environment ready for testing. // It initializes the repo, creates an initial commit on main, // checks out a feature branch, and sets up agent hooks. -func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { +func NewFeatureBranchEnv(t *testing.T) *TestEnv { t.Helper() env := NewTestEnv(t) @@ -105,7 +105,7 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { // Use `entire enable` to set up everything (hooks, settings, etc.) // This sets up .entire/settings.json and .claude/settings.json with hooks - env.RunEntireEnable(strategyName) + env.RunEntireEnable() // Commit all files created by `entire enable` so they survive git stash -u operations. // Without this, stash operations would stash away the hooks config and entire settings, @@ -119,13 +119,12 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { // RunEntireEnable runs `entire enable` to set up the project with hooks. // Uses the configured defaultAgent (from E2E_AGENT env var or "claude-code"). -func (env *TestEnv) RunEntireEnable(strategyName string) { +func (env *TestEnv) RunEntireEnable() { env.T.Helper() args := []string{ "enable", "--agent", defaultAgent, - "--strategy", strategyName, "--telemetry=false", "--force", // Force reinstall hooks in case they exist } diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 91f6cfdec..012d7919c 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -454,7 +454,7 @@ func TestFormatSessionInfo_WithSourceRef(t *testing.T) { session := &strategy.Session{ ID: "2025-12-09-test-session-abc", Description: "Test description", - Strategy: "auto-commit", + Strategy: "manual-commit", StartTime: now, Checkpoints: []strategy.Checkpoint{ { @@ -509,7 +509,7 @@ func TestFormatSessionInfo_CheckpointNumberingReversed(t *testing.T) { now := time.Now() session := &strategy.Session{ ID: "2025-12-09-test-session", - Strategy: "auto-commit", + Strategy: "manual-commit", StartTime: now.Add(-2 * time.Hour), Checkpoints: []strategy.Checkpoint{}, // Not used for format test } @@ -595,7 +595,7 @@ func TestFormatSessionInfo_CheckpointWithTaskMarker(t *testing.T) { now := time.Now() session := &strategy.Session{ ID: "2025-12-09-task-session", - Strategy: "auto-commit", + Strategy: "manual-commit", StartTime: now, Checkpoints: []strategy.Checkpoint{}, } @@ -626,7 +626,7 @@ func TestFormatSessionInfo_CheckpointWithDate(t *testing.T) { timestamp := time.Date(2025, 12, 10, 14, 35, 0, 0, time.UTC) session := &strategy.Session{ ID: "2025-12-10-dated-session", - Strategy: "auto-commit", + Strategy: "manual-commit", StartTime: timestamp, Checkpoints: []strategy.Checkpoint{}, } @@ -653,7 +653,7 @@ func TestFormatSessionInfo_ShowsMessageWhenNoInteractions(t *testing.T) { now := time.Now() session := &strategy.Session{ ID: "2025-12-12-incremental-session", - Strategy: "auto-commit", + Strategy: "manual-commit", StartTime: now, Checkpoints: []strategy.Checkpoint{}, } @@ -691,7 +691,7 @@ func TestFormatSessionInfo_ShowsMessageAndFilesWhenNoInteractions(t *testing.T) now := time.Now() session := &strategy.Session{ ID: "2025-12-12-incremental-with-files", - Strategy: "auto-commit", + Strategy: "manual-commit", StartTime: now, Checkpoints: []strategy.Checkpoint{}, } @@ -730,7 +730,7 @@ func TestFormatSessionInfo_DoesNotShowMessageWhenHasInteractions(t *testing.T) { now := time.Now() session := &strategy.Session{ ID: "2025-12-12-full-checkpoint", - Strategy: "auto-commit", + Strategy: "manual-commit", StartTime: now, Checkpoints: []strategy.Checkpoint{}, } @@ -3542,7 +3542,7 @@ func TestGetBranchCheckpoints_DefaultBranchFindsMergedCheckpoints(t *testing.T) if err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ CheckpointID: cpID, SessionID: "test-session", - Strategy: "auto-commit", + Strategy: "manual-commit", FilesTouched: []string{"test.txt"}, Prompts: []string{"add feature"}, }); err != nil { diff --git a/cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go b/cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go deleted file mode 100644 index b314580a2..000000000 --- a/cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go +++ /dev/null @@ -1,318 +0,0 @@ -//go:build integration - -package integration - -import ( - "strings" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" -) - -// TestDualStrategy_NoCheckpointForNoChanges verifies that the auto-commit strategy -// does NOT create a checkpoint when a prompt results in no file changes, -// even after a previous prompt that DID create file changes. -// -// This is the fix for ENT-70: auto-commit strategy was incorrectly triggering checkpoints -// because it parsed the entire transcript including file changes from previous prompts. -func TestDualStrategy_NoCheckpointForNoChanges(t *testing.T) { - t.Parallel() - - // Only run for auto-commit strategy - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) - - // Create a session - session := env.NewSession() - - // === FIRST PROMPT: Creates a file === - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) - } - - // Create a file (as if Claude Code wrote it) - env.WriteFile("feature.go", "package feature\n\nfunc Hello() {}\n") - - // Create transcript for first prompt - session.TranscriptBuilder.AddUserMessage("Create a hello function") - session.TranscriptBuilder.AddAssistantMessage("I'll create that for you.") - toolID := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "feature.go", "package feature\n\nfunc Hello() {}\n") - session.TranscriptBuilder.AddToolResult(toolID) - session.TranscriptBuilder.AddAssistantMessage("Done!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Get head hash before first stop - hashBeforeFirstStop := env.GetHeadHash() - - // Simulate stop for first prompt - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("First SimulateStop failed: %v", err) - } - - // Verify a commit was created (auto-commit creates commits on active branch) - hashAfterFirstStop := env.GetHeadHash() - if hashAfterFirstStop == hashBeforeFirstStop { - t.Error("Expected commit to be created after first prompt with file changes") - } - - // === SECOND PROMPT: No file changes === - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) - } - - // Add second prompt to transcript (no file changes this time) - session.TranscriptBuilder.AddUserMessage("What does the Hello function do?") - session.TranscriptBuilder.AddAssistantMessage("The Hello function is currently empty. It doesn't do anything yet.") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Simulate stop for second prompt - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Second SimulateStop failed: %v", err) - } - - // Verify NO new commit was created (this is the bug fix!) - hashAfterSecondStop := env.GetHeadHash() - if hashAfterSecondStop != hashAfterFirstStop { - t.Errorf("No commit should be created for prompt without file changes.\nHash after first stop: %s\nHash after second stop: %s", - hashAfterFirstStop, hashAfterSecondStop) - } - - // === THIRD PROMPT: Has file changes again === - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Third SimulateUserPromptSubmit failed: %v", err) - } - - // Create another file - env.WriteFile("feature2.go", "package feature\n\nfunc Goodbye() {}\n") - - // Add third prompt to transcript with file changes - session.TranscriptBuilder.AddUserMessage("Add a Goodbye function") - session.TranscriptBuilder.AddAssistantMessage("I'll add that.") - toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "feature2.go", "package feature\n\nfunc Goodbye() {}\n") - session.TranscriptBuilder.AddToolResult(toolID2) - session.TranscriptBuilder.AddAssistantMessage("Done!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Simulate stop for third prompt - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Third SimulateStop failed: %v", err) - } - - // Verify a commit WAS created for the third prompt - hashAfterThirdStop := env.GetHeadHash() - if hashAfterThirdStop == hashAfterSecondStop { - t.Error("Expected commit to be created after third prompt with file changes") - } -} - -// TestDualStrategy_IncrementalPromptContent verifies that each checkpoint only -// includes prompts since the last checkpoint, not the entire session history. -// -// This is the auto-commit equivalent of the manual-commit incremental condensation test. -// For auto-commit strategy, each checkpoint creates a commit, so the prompt.txt should only -// contain prompts from that specific checkpoint, not previous ones. -func TestDualStrategy_IncrementalPromptContent(t *testing.T) { - t.Parallel() - - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) - session := env.NewSession() - - // === FIRST PROMPT: Creates file A === - t.Log("Phase 1: First prompt creates file A") - - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) - } - - fileAContent := "package main\n\nfunc FunctionA() {}\n" - env.WriteFile("a.go", fileAContent) - - session.TranscriptBuilder.AddUserMessage("Create function A for the first feature") - session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.") - toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent) - session.TranscriptBuilder.AddToolResult(toolID1) - session.TranscriptBuilder.AddAssistantMessage("Done creating function A!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("First SimulateStop failed: %v", err) - } - - // Get checkpoint ID from first commit - commit1Hash := env.GetHeadHash() - checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash) - t.Logf("First checkpoint: %s (commit %s)", checkpoint1ID, commit1Hash[:7]) - - // Verify first checkpoint has prompt A (session files in numbered subdirectory) - prompt1Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint1ID, "prompt.txt")) - if !found { - t.Fatal("First checkpoint should have prompt.txt on entire/checkpoints/v1 branch") - } - t.Logf("First checkpoint prompt.txt:\n%s", prompt1Content) - - if !strings.Contains(prompt1Content, "function A") { - t.Error("First checkpoint prompt.txt should contain 'function A'") - } - - // === SECOND PROMPT: Creates file B === - t.Log("Phase 2: Second prompt creates file B") - - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) - } - - fileBContent := "package main\n\nfunc FunctionB() {}\n" - env.WriteFile("b.go", fileBContent) - - session.TranscriptBuilder.AddUserMessage("Create function B for the second feature") - session.TranscriptBuilder.AddAssistantMessage("I'll create function B for you.") - toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent) - session.TranscriptBuilder.AddToolResult(toolID2) - session.TranscriptBuilder.AddAssistantMessage("Done creating function B!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Second SimulateStop failed: %v", err) - } - - // Get checkpoint ID from second commit - commit2Hash := env.GetHeadHash() - checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash) - t.Logf("Second checkpoint: %s (commit %s)", checkpoint2ID, commit2Hash[:7]) - - if checkpoint1ID == checkpoint2ID { - t.Error("Checkpoints should have different IDs") - } - - // === VERIFY INCREMENTAL CONTENT === - t.Log("Phase 3: Verify second checkpoint only has prompt B (incremental)") - - // Session files are now in numbered subdirectory (e.g., 0/prompt.txt) - prompt2Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint2ID, "prompt.txt")) - if !found { - t.Fatal("Second checkpoint should have prompt.txt on entire/checkpoints/v1 branch") - } - t.Logf("Second checkpoint prompt.txt:\n%s", prompt2Content) - - // Should contain prompt B - if !strings.Contains(prompt2Content, "function B") { - t.Error("Second checkpoint prompt.txt should contain 'function B'") - } - - // Should NOT contain prompt A (already in first checkpoint) - if strings.Contains(prompt2Content, "function A") { - t.Error("Second checkpoint prompt.txt should NOT contain 'function A' (already in first checkpoint)") - } - - t.Log("Incremental prompt content test completed successfully!") -} - -// TestDualStrategy_SessionStateTracksTranscriptOffset verifies that session state -// correctly tracks the transcript offset (CheckpointTranscriptStart) across prompts. -// Note: cannot use t.Parallel() because we need t.Chdir to load session state. -func TestDualStrategy_SessionStateTracksTranscriptOffset(t *testing.T) { - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) - session := env.NewSession() - - // First prompt - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - // Session state is created by InitializeSession during UserPromptSubmit - // We need to change to the repo directory to load session state (it uses GetGitCommonDir) - t.Chdir(env.RepoDir) - state, err := strategy.LoadSessionState(session.ID) - if err != nil { - t.Fatalf("LoadSessionState failed: %v", err) - } - if state == nil { - t.Fatal("Session state should have been created by InitializeSession") - } - if state.CheckpointTranscriptStart != 0 { - t.Errorf("Initial CheckpointTranscriptStart should be 0, got %d", state.CheckpointTranscriptStart) - } - if state.StepCount != 0 { - t.Errorf("Initial StepCount should be 0, got %d", state.StepCount) - } - - // Create file and transcript - env.WriteFile("test.go", "package test") - session.CreateTranscript("Create test file", []FileChange{ - {Path: "test.go", Content: "package test"}, - }) - - // Simulate stop - this should update CheckpointTranscriptStart - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - // Verify session state was updated with transcript position - state, err = strategy.LoadSessionState(session.ID) - if err != nil { - t.Fatalf("LoadSessionState after stop failed: %v", err) - } - if state.CheckpointTranscriptStart == 0 { - t.Error("CheckpointTranscriptStart should have been updated after checkpoint") - } - if state.StepCount != 1 { - t.Errorf("StepCount should be 1, got %d", state.StepCount) - } - - // Second prompt - add more to transcript - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) - } - - // Modify a file - env.WriteFile("test.go", "package test\n\nfunc Test() {}\n") - session.TranscriptBuilder.AddUserMessage("Add a test function") - session.TranscriptBuilder.AddAssistantMessage("Adding test function.") - toolID := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "test.go", "package test\n\nfunc Test() {}\n") - session.TranscriptBuilder.AddToolResult(toolID) - session.TranscriptBuilder.AddAssistantMessage("Done!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Simulate second stop - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Second SimulateStop failed: %v", err) - } - - // Verify session state was updated again - state, err = strategy.LoadSessionState(session.ID) - if err != nil { - t.Fatalf("LoadSessionState after second stop failed: %v", err) - } - if state.StepCount != 2 { - t.Errorf("StepCount should be 2, got %d", state.StepCount) - } - // CheckpointTranscriptStart should be higher than after first stop - t.Logf("Final CheckpointTranscriptStart: %d, StepCount: %d", - state.CheckpointTranscriptStart, state.StepCount) -} diff --git a/cmd/entire/cli/integration_test/hooks_test.go b/cmd/entire/cli/integration_test/hooks_test.go index 6e3a568bc..9726a9fa4 100644 --- a/cmd/entire/cli/integration_test/hooks_test.go +++ b/cmd/entire/cli/integration_test/hooks_test.go @@ -142,9 +142,6 @@ func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { t.Fatalf("SimulateUserPromptSubmit failed: %v", err) } - // Record initial state for comparison - commitsBefore := env.GetGitLog() - // Create a file on disk (simulating what a subagent would write) env.WriteFile("subagent_output.go", "package main\n\nfunc SubagentWork() {}\n") @@ -193,34 +190,22 @@ func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } - // Verify checkpoint was created based on strategy type - switch strategyName { - case strategy.StrategyNameAutoCommit: - // Auto-commit creates a new commit on the active branch - commitsAfter := env.GetGitLog() - if len(commitsAfter) <= len(commitsBefore) { - t.Errorf("auto-commit: expected new commit to be created; commits before=%d, after=%d", - len(commitsBefore), len(commitsAfter)) - } - - case strategy.StrategyNameManualCommit: - // Manual-commit stores checkpoint data on the shadow branch - shadowBranch := env.GetShadowBranchName() - if !env.BranchExists(shadowBranch) { - t.Errorf("manual-commit: shadow branch %s should exist after checkpoint", shadowBranch) - } + // Verify checkpoint was created (manual-commit stores checkpoint data on the shadow branch) + shadowBranch := env.GetShadowBranchName() + if !env.BranchExists(shadowBranch) { + t.Errorf("shadow branch %s should exist after checkpoint", shadowBranch) + } - // Verify session state was updated with checkpoint count - state, stateErr := env.GetSessionState(session.ID) - if stateErr != nil { - t.Fatalf("failed to get session state: %v", stateErr) - } - if state == nil { - t.Fatal("manual-commit: session state should exist after checkpoint") - } - if state.StepCount == 0 { - t.Error("manual-commit: session state should have non-zero step count") - } + // Verify session state was updated with checkpoint count + state, stateErr := env.GetSessionState(session.ID) + if stateErr != nil { + t.Fatalf("failed to get session state: %v", stateErr) + } + if state == nil { + t.Fatal("session state should exist after checkpoint") + } + if state.StepCount == 0 { + t.Error("session state should have non-zero step count") } }) } diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index 785e0807b..b7b30a950 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -20,18 +20,11 @@ import ( const masterBranch = "master" -// Note: Resume tests only run with auto-commit strategy because: -// - Auto-commit strategy creates commits with Entire-Checkpoint trailers and metadata on entire/checkpoints/v1 -// immediately during SimulateStop -// - Manual-commit strategy only creates this structure after user commits (via prepare-commit-msg -// and post-commit hooks), which requires the full workflow tested in manual_commit_workflow_test.go -// Both strategies share the same resume code path once the structure exists. - // TestResume_SwitchBranchWithSession tests the resume command when switching to a branch // that has a commit with an Entire-Checkpoint trailer. func TestResume_SwitchBranchWithSession(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session on the feature branch session := env.NewSession() @@ -93,7 +86,7 @@ func TestResume_SwitchBranchWithSession(t *testing.T) { // TestResume_AlreadyOnBranch tests that resume works when already on the target branch. func TestResume_AlreadyOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session on the feature branch session := env.NewSession() @@ -130,7 +123,7 @@ func TestResume_AlreadyOnBranch(t *testing.T) { // any Entire-Checkpoint trailer in their history gracefully. func TestResume_NoCheckpointOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a branch directly from master (which has no checkpoints) // Switch to master first @@ -169,7 +162,7 @@ func TestResume_NoCheckpointOnBranch(t *testing.T) { // TestResume_BranchDoesNotExist tests that resume returns an error for non-existent branches. func TestResume_BranchDoesNotExist(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Try to resume a non-existent branch output, err := env.RunResume("nonexistent-branch") @@ -188,7 +181,7 @@ func TestResume_BranchDoesNotExist(t *testing.T) { // TestResume_UncommittedChanges tests that resume fails when there are uncommitted changes. func TestResume_UncommittedChanges(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create another branch env.GitCheckoutNewBranch("feature/target") @@ -220,7 +213,7 @@ func TestResume_UncommittedChanges(t *testing.T) { // with the checkpoint's version. This ensures consistency when resuming from a different device. func TestResume_SessionLogAlreadyExists(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session session := env.NewSession() @@ -284,7 +277,7 @@ func TestResume_SessionLogAlreadyExists(t *testing.T) { // ensuring it uses the session from the last commit. func TestResume_MultipleSessionsOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create first session session1 := env.NewSession() @@ -348,7 +341,7 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { // This can happen if the metadata branch was corrupted or reset. func TestResume_CheckpointWithoutMetadata(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // First create a real session so the entire/checkpoints/v1 branch exists session := env.NewSession() @@ -404,7 +397,7 @@ func TestResume_CheckpointWithoutMetadata(t *testing.T) { // Since the only "newer" commits are merge commits, no confirmation should be required. func TestResume_AfterMergingMain(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session on the feature branch session := env.NewSession() @@ -580,7 +573,7 @@ func (env *TestEnv) GitCheckoutBranch(branchName string) { // and does NOT overwrite the local log. This ensures safe behavior in CI environments. func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -638,7 +631,7 @@ func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { // and overwrites the local log. func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -697,7 +690,7 @@ func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { // confirms the overwrite prompt interactively, the local log is overwritten. func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -761,7 +754,7 @@ func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { // declines the overwrite prompt interactively, the local log is preserved. func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -826,7 +819,7 @@ func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { // than local log, resume proceeds without requiring --force. func TestResume_CheckpointNewerTimestamp(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session session := env.NewSession() @@ -992,7 +985,7 @@ func TestResume_MultiSessionMixedTimestamps(t *testing.T) { // resume proceeds without requiring --force (treated as new). func TestResume_LocalLogNoTimestamp(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session session := env.NewSession() diff --git a/cmd/entire/cli/integration_test/rewind_test.go b/cmd/entire/cli/integration_test/rewind_test.go index d93f1588d..e6f9034ba 100644 --- a/cmd/entire/cli/integration_test/rewind_test.go +++ b/cmd/entire/cli/integration_test/rewind_test.go @@ -437,49 +437,3 @@ func TestRewind_MultipleConsecutive(t *testing.T) { }) } - -// TestRewind_DifferentSessions tests that commit and auto-commit strategies support -// multiple different sessions without committing, while manual-commit strategy requires -// the same session (or a commit between sessions). -func TestRewind_DifferentSessions(t *testing.T) { - t.Parallel() - - t.Run("auto_commit_supports_different_sessions", func(t *testing.T) { - t.Parallel() - for _, strategyName := range []string{"auto-commit"} { - strategyName := strategyName // capture for parallel - t.Run(strategyName, func(t *testing.T) { - t.Parallel() - env := NewFeatureBranchEnv(t, strategyName) - - // Session 1 - session1 := env.NewSession() - if err := env.SimulateUserPromptSubmit(session1.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit session1 failed: %v", err) - } - env.WriteFile("file.txt", "version 1") - session1.CreateTranscript("Create file", []FileChange{{Path: "file.txt", Content: "version 1"}}) - if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil { - t.Fatalf("SimulateStop session1 failed: %v", err) - } - - // Session 2 (different session ID, no commit between) - session2 := env.NewSession() - if err := env.SimulateUserPromptSubmit(session2.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit session2 failed: %v", err) - } - env.WriteFile("file.txt", "version 2") - session2.CreateTranscript("Update file", []FileChange{{Path: "file.txt", Content: "version 2"}}) - if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil { - t.Fatalf("SimulateStop session2 failed: %v", err) - } - - // Both sessions should create rewind points - points := env.GetRewindPoints() - if len(points) != 2 { - t.Errorf("expected 2 rewind points, got %d", len(points)) - } - }) - } - }) -} diff --git a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go index a3d614c91..da96068b2 100644 --- a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go +++ b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go @@ -290,17 +290,9 @@ func TestSubagentCheckpoints_NoPreTaskFile(t *testing.T) { func verifyCheckpointStorage(t *testing.T, env *TestEnv, strategyName, sessionID, taskToolUseID string) { t.Helper() - switch strategyName { - case strategy.StrategyNameManualCommit: - // Shadow strategy stores checkpoints in git tree on shadow branch (entire/) - // We need to verify that checkpoint data exists in the shadow branch tree - verifyShadowCheckpointStorage(t, env, sessionID, taskToolUseID) - - case strategy.StrategyNameAutoCommit: - // Dual strategy stores metadata on orphan entire/checkpoints/v1 branch - // Verify that commits were created (incremental + final) - t.Logf("Note: auto-commit strategy stores checkpoints in entire/checkpoints/v1 branch") - } + // Manual-commit stores checkpoints in git tree on shadow branch (entire/) + // We need to verify that checkpoint data exists in the shadow branch tree + verifyShadowCheckpointStorage(t, env, sessionID, taskToolUseID) } // verifyShadowCheckpointStorage verifies that checkpoints are stored in the shadow branch git tree. diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index bc2debf57..faa8ca8fc 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -196,7 +196,6 @@ func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { // AllStrategies returns all strategy names for parameterized tests. func AllStrategies() []string { return []string{ - strategy.StrategyNameAutoCommit, strategy.StrategyNameManualCommit, } } @@ -476,7 +475,7 @@ func (env *TestEnv) GitCommitWithMetadata(message, metadataDir string) { } // GitCommitWithCheckpointID creates a commit with Entire-Checkpoint trailer. -// This simulates commits created by the auto-commit strategy. +// This simulates commits. func (env *TestEnv) GitCommitWithCheckpointID(message, checkpointID string) { env.T.Helper() diff --git a/cmd/entire/cli/integration_test/testenv_test.go b/cmd/entire/cli/integration_test/testenv_test.go index ff328b554..0d47c02db 100644 --- a/cmd/entire/cli/integration_test/testenv_test.go +++ b/cmd/entire/cli/integration_test/testenv_test.go @@ -56,7 +56,7 @@ func TestTestEnv_InitEntire(t *testing.T) { t.Error(".entire directory should exist") } - // Verify settings file exists and contains strategy + // Verify settings file exists and contains enabled settingsPath := filepath.Join(entireDir, paths.SettingsFileName) data, err := os.ReadFile(settingsPath) if err != nil { @@ -64,9 +64,8 @@ func TestTestEnv_InitEntire(t *testing.T) { } settingsContent := string(data) - expectedStrategy := `"strategy": "` + strategyName + `"` - if !strings.Contains(settingsContent, expectedStrategy) { - t.Errorf("settings.json should contain %s, got: %s", expectedStrategy, settingsContent) + if !strings.Contains(settingsContent, `"enabled"`) { + t.Errorf("settings.json should contain enabled field, got: %s", settingsContent) } // Verify tmp directory exists @@ -211,12 +210,12 @@ func TestNewFeatureBranchEnv(t *testing.T) { func TestAllStrategies(t *testing.T) { t.Parallel() strategies := AllStrategies() - if len(strategies) != 2 { + if len(strategies) != 1 { t.Errorf("AllStrategies() returned %d strategies, want 2", len(strategies)) } // Verify expected strategies are present - expected := []string{"auto-commit", "manual-commit"} + expected := []string{"manual-commit"} for _, exp := range expected { found := false for _, s := range strategies { diff --git a/cmd/entire/cli/integration_test/worktree_test.go b/cmd/entire/cli/integration_test/worktree_test.go index 4cda6f275..2a3f62145 100644 --- a/cmd/entire/cli/integration_test/worktree_test.go +++ b/cmd/entire/cli/integration_test/worktree_test.go @@ -22,9 +22,9 @@ import ( // // NOTE: This test uses os.Chdir() so it cannot use t.Parallel(). func TestWorktreeCommitPersistence(t *testing.T) { - // Only test auto-commit strategy - it creates commits on the working branch + // Test worktree commit persistence with manual-commit strategy worktreeStrategies := []string{ - strategy.StrategyNameAutoCommit, + strategy.StrategyNameManualCommit, } RunForStrategiesSequential(t, worktreeStrategies, func(t *testing.T, strat string) { diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 217efc7cd..68a6d100f 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -131,12 +131,11 @@ func handleLifecycleTurnStart(ag agent.Agent, event *agent.Event) error { } // Ensure strategy setup and initialize session - strat := GetStrategy() - - if err := strat.EnsureSetup(); err != nil { + if err := strategy.EnsureSetup(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) } + strat := GetStrategy() if initializer, ok := strat.(strategy.SessionInitializer); ok { agentType := ag.Type() if err := initializer.InitializeSession(sessionID, agentType, event.SessionRef, event.Prompt); err != nil { @@ -219,7 +218,6 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { var allPrompts []string var summary string var modifiedFiles []string - var newTranscriptPosition int // Compute subagents directory for agents that support subagent extraction. // Subagent transcripts live in //subagents/ @@ -247,17 +245,12 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { } else { modifiedFiles = files } - // Get position from basic analyzer - if _, pos, posErr := analyzer.ExtractModifiedFilesFromOffset(transcriptRef, transcriptOffset); posErr == nil { - newTranscriptPosition = pos - } } else { // Fall back to basic extraction (main transcript only) - if files, pos, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptRef, transcriptOffset); fileErr != nil { + if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptRef, transcriptOffset); fileErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to extract modified files: %v\n", fileErr) } else { modifiedFiles = files - newTranscriptPosition = pos } } } @@ -398,12 +391,7 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { return fmt.Errorf("failed to save step: %w", err) } - // Update session state transcript position for auto-commit strategy - if strat.Name() == strategy.StrategyNameAutoCommit && newTranscriptPosition > 0 { - updateAutoCommitTranscriptPosition(sessionID, newTranscriptPosition) - } - - // Transition session phase and cleanup pre-prompt state + // Transition session phase and cleanup transitionSessionTurnEnd(sessionID) if cleanupErr := CleanupPrePromptState(sessionID); cleanupErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to cleanup pre-prompt state: %v\n", cleanupErr) @@ -626,7 +614,7 @@ func resolveTranscriptOffset(preState *PrePromptState, sessionID string) int { return preState.TranscriptOffset } - // Fall back to session state (e.g., auto-commit strategy updates it after each save) + // Fall back to session state sessionState, loadErr := strategy.LoadSessionState(sessionID) if loadErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to load session state: %v\n", loadErr) @@ -640,29 +628,6 @@ func resolveTranscriptOffset(preState *PrePromptState, sessionID string) int { return 0 } -// updateAutoCommitTranscriptPosition updates the session state with the new transcript position -// for the auto-commit strategy. -func updateAutoCommitTranscriptPosition(sessionID string, newPosition int) { - sessionState, loadErr := strategy.LoadSessionState(sessionID) - if loadErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to load session state: %v\n", loadErr) - return - } - if sessionState == nil { - sessionState = &strategy.SessionState{ - SessionID: sessionID, - } - } - sessionState.CheckpointTranscriptStart = newPosition - sessionState.StepCount++ - if updateErr := strategy.SaveSessionState(sessionState); updateErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to update session state: %v\n", updateErr) - } else { - fmt.Fprintf(os.Stderr, "Updated session state: transcript position=%d, checkpoint=%d\n", - newPosition, sessionState.StepCount) - } -} - // createContextFile creates a context.md file for the session checkpoint. // This is a unified version that works for all agents. func createContextFile(contextFile, commitMessage, sessionID string, prompts []string, summary string) error { diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index cc82a13b9..6637d3fe3 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -33,7 +33,7 @@ const ( SettingsFileName = "settings.json" ) -// MetadataBranchName is the orphan branch used by auto-commit and manual-commit strategies to store metadata +// MetadataBranchName is the orphan branch used by manual-commit strategie to store metadata const MetadataBranchName = "entire/checkpoints/v1" // CheckpointPath returns the sharded storage path for a checkpoint ID. diff --git a/cmd/entire/cli/reset.go b/cmd/entire/cli/reset.go index ebc5a9d8e..a84c709a5 100644 --- a/cmd/entire/cli/reset.go +++ b/cmd/entire/cli/reset.go @@ -22,8 +22,6 @@ func newResetCmd() *cobra.Command { This allows starting fresh without existing checkpoints on your current commit. -Only works with the manual-commit strategy. For auto-commit strategy, -use Git directly: git reset --hard The command will: - Find all sessions where base_commit matches the current HEAD diff --git a/cmd/entire/cli/reset_test.go b/cmd/entire/cli/reset_test.go index 69d3fbd6a..b43cf8a21 100644 --- a/cmd/entire/cli/reset_test.go +++ b/cmd/entire/cli/reset_test.go @@ -232,30 +232,6 @@ func TestResetCmd_NotGitRepo(t *testing.T) { } } -func TestResetCmd_AutoCommitStrategy(t *testing.T) { - setupResetTestRepo(t) - - // Write auto-commit strategy settings - writeSettings(t, `{"strategy": "auto-commit", "enabled": true}`) - - // Run reset - cmd := newResetCmd() - var stdout, stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - - err := cmd.Execute() - if err == nil { - t.Fatal("reset command should return error for auto-commit strategy") - } - - // Verify helpful error message - output := stderr.String() - if !strings.Contains(output, "strategy auto-commit does not support reset") { - t.Errorf("Expected message about auto-commit strategy, got: %s", output) - } -} - func TestResetCmd_MultipleSessions(t *testing.T) { repo, commitHash := setupResetTestRepo(t) diff --git a/cmd/entire/cli/resume_test.go b/cmd/entire/cli/resume_test.go index f2430e381..636271419 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -209,74 +209,6 @@ func TestResumeFromCurrentBranch_NoCheckpoint(t *testing.T) { } } -func TestResumeFromCurrentBranch_WithEntireCheckpointTrailer(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // Set up a fake Claude project directory for testing - claudeDir := filepath.Join(tmpDir, "claude-projects") - t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", claudeDir) - - _, _, _ = setupResumeTestRepo(t, tmpDir, false) - - // Set up the auto-commit strategy and create checkpoint metadata on entire/checkpoints/v1 branch - strat := strategy.NewAutoCommitStrategy() - if err := strat.EnsureSetup(); err != nil { - t.Fatalf("Failed to ensure setup: %v", err) - } - - // Create metadata directory with session log (required for SaveStep) - sessionID := "4f8c1176-7025-4530-a860-c6fc4c63a150" - sessionLogContent := `{"type":"test"}` - metadataDir := filepath.Join(tmpDir, paths.EntireMetadataDir, sessionID) - if err := os.MkdirAll(metadataDir, 0o755); err != nil { - t.Fatalf("Failed to create metadata dir: %v", err) - } - logFile := filepath.Join(metadataDir, paths.TranscriptFileName) - if err := os.WriteFile(logFile, []byte(sessionLogContent), 0o644); err != nil { - t.Fatalf("Failed to write log file: %v", err) - } - - // Create a file change to commit - testFile := filepath.Join(tmpDir, "test.txt") - if err := os.WriteFile(testFile, []byte("metadata content"), 0o644); err != nil { - t.Fatalf("Failed to write test file: %v", err) - } - - // Use SaveStep to create a commit with checkpoint metadata on entire/checkpoints/v1 branch - ctx := strategy.StepContext{ - CommitMessage: "test commit with checkpoint", - MetadataDir: filepath.Join(paths.EntireMetadataDir, sessionID), - MetadataDirAbs: metadataDir, - NewFiles: []string{}, - ModifiedFiles: []string{"test.txt"}, - DeletedFiles: []string{}, - AuthorName: "Test User", - AuthorEmail: "test@example.com", - } - if err := strat.SaveStep(ctx); err != nil { - t.Fatalf("Failed to save changes: %v", err) - } - - // Run resumeFromCurrentBranch - err := resumeFromCurrentBranch("master", false) - if err != nil { - t.Errorf("resumeFromCurrentBranch() returned error: %v", err) - } - - // Verify that the session log was written to the Claude project directory - expectedLogPath := filepath.Join(claudeDir, sessionID+".jsonl") - - content, err := os.ReadFile(expectedLogPath) - if err != nil { - t.Fatalf("Failed to read session log from Claude project dir: %v (expected the log to be restored)", err) - } - - if string(content) != sessionLogContent { - t.Errorf("Session log content mismatch.\nGot: %s\nWant: %s", string(content), sessionLogContent) - } -} - func TestRunResume_AlreadyOnBranch(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) @@ -361,8 +293,7 @@ func createCheckpointOnMetadataBranch(t *testing.T, repo *git.Repository, sessio metadataJSON := fmt.Sprintf(`{ "checkpoint_id": %q, "session_id": %q, - "created_at": "2025-01-01T00:00:00Z", - "strategy": "auto-commit" + "created_at": "2025-01-01T00:00:00Z" }`, checkpointID.String(), sessionID) // Create blob for metadata diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 5fedf6ad4..c9e9243f8 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -5,6 +5,7 @@ import ( "runtime" "github.com/entireio/cli/cmd/entire/cli/buildinfo" + "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/telemetry" "github.com/entireio/cli/cmd/entire/cli/versioncheck" "github.com/spf13/cobra" @@ -58,7 +59,7 @@ func NewRootCmd() *cobra.Command { // Use detached tracking (non-blocking) installedAgents := GetAgentsWithHooksInstalled() agentStr := JoinAgentNames(installedAgents) - telemetry.TrackCommandDetached(cmd, settings.Strategy, agentStr, settings.Enabled, buildinfo.Version) + telemetry.TrackCommandDetached(cmd, strategy.StrategyNameManualCommit, agentStr, settings.Enabled, buildinfo.Version) } // Version check and notification (synchronous with 2s timeout) @@ -81,7 +82,6 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newHooksCmd()) cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newExplainCmd()) - cmd.AddCommand(newDebugCmd()) cmd.AddCommand(newDoctorCmd()) cmd.AddCommand(newSendAnalyticsCmd()) cmd.AddCommand(newCurlBashPostInstallCmd()) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 1249eb01c..03d3e4870 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -14,10 +14,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" ) -// DefaultStrategyName is the default strategy when none is configured. -// This is duplicated here to avoid importing the strategy package (which would create a cycle). -const DefaultStrategyName = "manual-commit" - const ( // EntireSettingsFile is the path to the Entire settings file EntireSettingsFile = ".entire/settings.json" @@ -27,8 +23,6 @@ const ( // EntireSettings represents the .entire/settings.json configuration type EntireSettings struct { - // Strategy is the name of the git strategy to use - Strategy string `json:"strategy"` // Enabled indicates whether Entire is active. When false, CLI commands // show a disabled message and hooks exit silently. Defaults to true. @@ -85,8 +79,6 @@ func Load() (*EntireSettings, error) { } } - applyDefaults(settings) - return settings, nil } @@ -101,8 +93,7 @@ func LoadFromFile(filePath string) (*EntireSettings, error) { // Returns default settings if the file doesn't exist. func loadFromFile(filePath string) (*EntireSettings, error) { settings := &EntireSettings{ - Strategy: DefaultStrategyName, - Enabled: true, // Default to enabled + Enabled: true, // Default to enabled } data, err := os.ReadFile(filePath) //nolint:gosec // path is from caller @@ -118,7 +109,6 @@ func loadFromFile(filePath string) (*EntireSettings, error) { if err := dec.Decode(settings); err != nil { return nil, fmt.Errorf("parsing settings file: %w", err) } - applyDefaults(settings) return settings, nil } @@ -140,17 +130,6 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return fmt.Errorf("parsing JSON: %w", err) } - // Override strategy if present and non-empty - if strategyRaw, ok := raw["strategy"]; ok { - var s string - if err := json.Unmarshal(strategyRaw, &s); err != nil { - return fmt.Errorf("parsing strategy field: %w", err) - } - if s != "" { - settings.Strategy = s - } - } - // Override enabled if present if enabledRaw, ok := raw["enabled"]; ok { var e bool @@ -207,12 +186,6 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return nil } -func applyDefaults(settings *EntireSettings) { - if settings.Strategy == "" { - settings.Strategy = DefaultStrategyName - } -} - // IsSetUp returns true if Entire has been set up in the current repository. // This checks if .entire/settings.json exists. // Use this to avoid creating files/directories in repos where Entire was never enabled. diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index ad09bc57a..1a037fa28 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -19,7 +19,7 @@ func TestLoad_RejectsUnknownKeys(t *testing.T) { // Create settings.json with an unknown key settingsFile := filepath.Join(entireDir, "settings.json") - settingsContent := `{"strategy": "manual-commit", "unknown_key": "value"}` + settingsContent := `{"enabled": true, "unknown_key": "value"}` if err := os.WriteFile(settingsFile, []byte(settingsContent), 0644); err != nil { t.Fatalf("failed to write settings file: %v", err) } @@ -54,7 +54,6 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { // Create settings.json with all valid keys settingsFile := filepath.Join(entireDir, "settings.json") settingsContent := `{ - "strategy": "auto-commit", "enabled": true, "local_dev": false, "log_level": "debug", @@ -80,9 +79,6 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { } // Verify values - if settings.Strategy != "auto-commit" { - t.Errorf("expected strategy 'auto-commit', got %q", settings.Strategy) - } if !settings.Enabled { t.Error("expected enabled to be true") } @@ -106,7 +102,7 @@ func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) { // Create valid settings.json settingsFile := filepath.Join(entireDir, "settings.json") - settingsContent := `{"strategy": "manual-commit"}` + settingsContent := `{"enabled": true}` if err := os.WriteFile(settingsFile, []byte(settingsContent), 0644); err != nil { t.Fatalf("failed to write settings file: %v", err) } diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 3660e012f..da17bbafb 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -20,37 +20,18 @@ import ( "github.com/spf13/pflag" ) -// Strategy display names for user-friendly selection -const ( - strategyDisplayManualCommit = "manual-commit" - strategyDisplayAutoCommit = "auto-commit" -) - // Config path display strings const ( configDisplayProject = ".entire/settings.json" configDisplayLocal = ".entire/settings.local.json" ) -// strategyDisplayToInternal maps user-friendly names to internal strategy names -var strategyDisplayToInternal = map[string]string{ - strategyDisplayManualCommit: strategy.StrategyNameManualCommit, - strategyDisplayAutoCommit: strategy.StrategyNameAutoCommit, -} - -// strategyInternalToDisplay maps internal strategy names to user-friendly names -var strategyInternalToDisplay = map[string]string{ - strategy.StrategyNameManualCommit: strategyDisplayManualCommit, - strategy.StrategyNameAutoCommit: strategyDisplayAutoCommit, -} - func newEnableCmd() *cobra.Command { var localDev bool var ignoreUntracked bool var useLocalSettings bool var useProjectSettings bool var agentName string - var strategyFlag string var forceHooks bool var skipPushSessions bool var telemetry bool @@ -60,11 +41,8 @@ func newEnableCmd() *cobra.Command { Short: "Enable Entire in current project", Long: `Enable Entire with session tracking for your AI agent workflows. -Uses the manual-commit strategy by default. To use a different strategy: - - entire enable --strategy auto-commit - -Strategies: manual-commit (default), auto-commit`, +Uses the manual-commit strategy, which creates session checkpoints without +modifying your active branch.`, RunE: func(cmd *cobra.Command, _ []string) error { // Check if we're in a git repository first - this is a prerequisite error, // not a usage error, so we silence Cobra's output and use SilentError @@ -100,7 +78,7 @@ Strategies: manual-commit (default), auto-commit`, // --agent is a targeted operation: set up this specific agent without // affecting other agents. Unlike the interactive path, it does not // uninstall hooks for other previously-enabled agents. - return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, strategyFlag, localDev, forceHooks, skipPushSessions, telemetry) + return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, localDev, forceHooks, skipPushSessions, telemetry) } // Detect or prompt for agents agents, err := detectOrSelectAgent(cmd.OutOrStdout(), nil) @@ -108,9 +86,6 @@ Strategies: manual-commit (default), auto-commit`, return fmt.Errorf("agent selection failed: %w", err) } - if strategyFlag != "" { - return runEnableWithStrategy(cmd.OutOrStdout(), agents, strategyFlag, localDev, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry) - } return runEnableInteractive(cmd.OutOrStdout(), agents, localDev, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry) }, } @@ -122,14 +97,9 @@ Strategies: manual-commit (default), auto-commit`, cmd.Flags().BoolVar(&useLocalSettings, "local", false, "Write settings to .entire/settings.local.json instead of .entire/settings.json") cmd.Flags().BoolVar(&useProjectSettings, "project", false, "Write settings to .entire/settings.json even if it already exists") cmd.Flags().StringVar(&agentName, "agent", "", "Agent to set up hooks for (e.g., claude-code, gemini, opencode). Enables non-interactive mode.") - cmd.Flags().StringVar(&strategyFlag, "strategy", "", "Strategy to use (manual-commit or auto-commit)") cmd.Flags().BoolVarP(&forceHooks, "force", "f", false, "Force reinstall hooks (removes existing Entire hooks first)") cmd.Flags().BoolVar(&skipPushSessions, "skip-push-sessions", false, "Disable automatic pushing of session logs on git push") cmd.Flags().BoolVar(&telemetry, "telemetry", true, "Enable anonymous usage analytics") - //nolint:errcheck,gosec // completion is optional, flag is defined above - cmd.RegisterFlagCompletionFunc("strategy", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{strategyDisplayManualCommit, strategyDisplayAutoCommit}, cobra.ShellCompDirectiveNoFileComp - }) // Provide a helpful error when --agent is used without a value defaultFlagErr := cmd.FlagErrorFunc() @@ -182,107 +152,6 @@ To completely remove Entire integrations from this repository, use --uninstall: return cmd } -// runEnableWithStrategy enables Entire with a specified strategy (non-interactive). -// The selectedStrategy can be either a display name (manual-commit, auto-commit) -// or an internal name (manual-commit, auto-commit). -// agents must be provided by the caller (via detectOrSelectAgent). -func runEnableWithStrategy(w io.Writer, agents []agent.Agent, selectedStrategy string, localDev, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry bool) error { - // Map the strategy to internal name if it's a display name - internalStrategy := selectedStrategy - if mapped, ok := strategyDisplayToInternal[selectedStrategy]; ok { - internalStrategy = mapped - } - - // Validate the strategy exists - strat, err := strategy.Get(internalStrategy) - if err != nil { - return fmt.Errorf("unknown strategy: %s (use manual-commit or auto-commit)", selectedStrategy) - } - - // Uninstall hooks for agents that were previously active but are no longer selected - if err := uninstallDeselectedAgentHooks(w, agents); err != nil { - return fmt.Errorf("failed to clean up deselected agents: %w", err) - } - - // Setup agent hooks for all selected agents - for _, ag := range agents { - if _, err := setupAgentHooks(ag, localDev, forceHooks); err != nil { - return fmt.Errorf("failed to setup %s hooks: %w", ag.Type(), err) - } - } - - // Setup .entire directory - if _, err := setupEntireDirectory(); err != nil { - return fmt.Errorf("failed to setup .entire directory: %w", err) - } - - // Load existing settings to preserve other options (like strategy_options.push) - settings, err := LoadEntireSettings() - if err != nil { - // If we can't load, start with defaults - settings = &EntireSettings{} - } - // Update the specific fields - settings.Strategy = internalStrategy - settings.LocalDev = localDev - settings.Enabled = true - - // Set push_sessions option if --skip-push-sessions flag was provided - if skipPushSessions { - if settings.StrategyOptions == nil { - settings.StrategyOptions = make(map[string]interface{}) - } - settings.StrategyOptions["push_sessions"] = false - } - - // Handle telemetry for non-interactive mode - // Note: if telemetry is nil (not configured), it defaults to disabled - if !telemetry || os.Getenv("ENTIRE_TELEMETRY_OPTOUT") != "" { - f := false - settings.Telemetry = &f - } - - // Determine which settings file to write to - entireDirAbs, err := paths.AbsPath(paths.EntireDir) - if err != nil { - entireDirAbs = paths.EntireDir // Fallback to relative - } - shouldUseLocal, showNotification := determineSettingsTarget(entireDirAbs, useLocalSettings, useProjectSettings) - - if showNotification { - fmt.Fprintln(w, "Info: Project settings exist. Saving to settings.local.json instead.") - fmt.Fprintln(w, " Use --project to update the project settings file.") - } - - configDisplay := configDisplayProject - if shouldUseLocal { - if err := SaveEntireSettingsLocal(settings); err != nil { - return fmt.Errorf("failed to save local settings: %w", err) - } - configDisplay = configDisplayLocal - } else { - if err := SaveEntireSettings(settings); err != nil { - return fmt.Errorf("failed to save settings: %w", err) - } - } - - if _, err := strategy.InstallGitHook(true, localDev); err != nil { - return fmt.Errorf("failed to install git hooks: %w", err) - } - strategy.CheckAndWarnHookManagers(w, localDev) - fmt.Fprintln(w, "✓ Hooks installed") - fmt.Fprintf(w, "✓ Project configured (%s)\n", configDisplay) - - // Let the strategy handle its own setup requirements - if err := strat.EnsureSetup(); err != nil { - return fmt.Errorf("failed to setup strategy: %w", err) - } - - fmt.Fprintln(w, "\nReady.") - - return nil -} - // runEnableInteractive runs the interactive enable flow. // agents must be provided by the caller (via detectOrSelectAgent). func runEnableInteractive(w io.Writer, agents []agent.Agent, localDev, useLocalSettings, useProjectSettings, forceHooks, skipPushSessions, telemetry bool) error { @@ -303,9 +172,6 @@ func runEnableInteractive(w io.Writer, agents []agent.Agent, localDev, useLocalS return fmt.Errorf("failed to setup .entire directory: %w", err) } - // Use the default strategy (manual-commit) - internalStrategy := strategy.DefaultStrategyName - // Load existing settings to preserve other options (like strategy_options.push) settings, err := LoadEntireSettings() if err != nil { @@ -313,7 +179,6 @@ func runEnableInteractive(w io.Writer, agents []agent.Agent, localDev, useLocalS settings = &EntireSettings{} } // Update the specific fields - settings.Strategy = internalStrategy settings.LocalDev = localDev settings.Enabled = true @@ -373,12 +238,7 @@ func runEnableInteractive(w io.Writer, agents []agent.Agent, localDev, useLocalS return fmt.Errorf("failed to save settings: %w", err) } - // Let the strategy handle its own setup requirements - strat, err := strategy.Get(internalStrategy) - if err != nil { - return fmt.Errorf("failed to get strategy: %w", err) - } - if err := strat.EnsureSetup(); err != nil { + if err := strategy.EnsureSetup(); err != nil { return fmt.Errorf("failed to setup strategy: %w", err) } @@ -484,7 +344,7 @@ func uninstallDeselectedAgentHooks(w io.Writer, selectedAgents []agent.Agent) er // setupAgentHooks sets up hooks for a given agent. // Returns the number of hooks installed (0 if already installed). -func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { //nolint:unparam // return value used by setupAgentHooksNonInteractive +func setupAgentHooks(ag agent.Agent, localDev, forceHooks bool) (int, error) { hookAgent, ok := ag.(agent.HookSupport) if !ok { return 0, fmt.Errorf("agent %s does not support hooks", ag.Name()) @@ -703,7 +563,7 @@ func printWrongAgentError(w io.Writer, name string) { // setupAgentHooksNonInteractive sets up hooks for a specific agent non-interactively. // If strategyName is provided, it sets the strategy; otherwise uses default. -func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName string, localDev, forceHooks, skipPushSessions, telemetry bool) error { +func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, localDev, forceHooks, skipPushSessions, telemetry bool) error { agentName := ag.Name() // Check if agent supports hooks hookAgent, ok := ag.(agent.HookSupport) @@ -728,7 +588,7 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str settings, err := LoadEntireSettings() if err != nil { // If we can't load, start with defaults - settings = &EntireSettings{Strategy: strategy.DefaultStrategyName} + settings = &EntireSettings{} } settings.Enabled = true if localDev { @@ -743,20 +603,6 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str settings.StrategyOptions["push_sessions"] = false } - // Set strategy if provided - if strategyName != "" { - // Map display name to internal name if needed - internalStrategy := strategyName - if mapped, ok := strategyDisplayToInternal[strategyName]; ok { - internalStrategy = mapped - } - // Validate the strategy exists - if _, err := strategy.Get(internalStrategy); err != nil { - return fmt.Errorf("unknown strategy: %s (use manual-commit or auto-commit)", strategyName) - } - settings.Strategy = internalStrategy - } - // Handle telemetry for non-interactive mode // Note: if telemetry is nil (not configured), it defaults to disabled if !telemetry || os.Getenv("ENTIRE_TELEMETRY_OPTOUT") != "" { @@ -789,12 +635,7 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str fmt.Fprintf(w, "✓ Project configured (%s)\n", configDisplayProject) - // Let the strategy handle its own setup requirements (creates entire/checkpoints/v1 branch, etc.) - strat, err := strategy.Get(settings.Strategy) - if err != nil { - return fmt.Errorf("failed to get strategy: %w", err) - } - if err := strat.EnsureSetup(); err != nil { + if err := strategy.EnsureSetup(); err != nil { return fmt.Errorf("failed to setup strategy: %w", err) } diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 01c19bd68..8fc287471 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -345,93 +345,6 @@ func TestDetermineSettingsTarget_SettingsNotExists_NoFlags(t *testing.T) { } } -func TestRunEnableWithStrategy_PreservesExistingSettings(t *testing.T) { - setupTestRepo(t) - - // Create initial settings with strategy_options (like push enabled) - initialSettings := `{ - "strategy": "manual-commit", - "enabled": true, - "strategy_options": { - "push": true, - "some_other_option": "value" - } - }` - writeSettings(t, initialSettings) - - // Run enable with a different strategy — pass agents directly (no TTY needed) - defaultAgent := agent.Default() - var stdout bytes.Buffer - err := runEnableWithStrategy(&stdout, []agent.Agent{defaultAgent}, "auto-commit", false, false, true, false, false, false) - if err != nil { - t.Fatalf("runEnableWithStrategy() error = %v", err) - } - - // Load the saved settings and verify strategy_options were preserved - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - - // Strategy should be updated - if settings.Strategy != "auto-commit" { - t.Errorf("Strategy should be 'auto-commit', got %q", settings.Strategy) - } - - // strategy_options should be preserved - if settings.StrategyOptions == nil { - t.Fatal("strategy_options should be preserved, but got nil") - } - if settings.StrategyOptions["push"] != true { - t.Errorf("strategy_options.push should be true, got %v", settings.StrategyOptions["push"]) - } - if settings.StrategyOptions["some_other_option"] != "value" { - t.Errorf("strategy_options.some_other_option should be 'value', got %v", settings.StrategyOptions["some_other_option"]) - } -} - -func TestRunEnableWithStrategy_PreservesLocalSettings(t *testing.T) { - setupTestRepo(t) - - // Create project settings - writeSettings(t, `{"strategy": "manual-commit", "enabled": true}`) - - // Create local settings with strategy_options - localSettings := `{ - "strategy_options": { - "push": true - } - }` - writeLocalSettings(t, localSettings) - - // Run enable with --local flag — pass agents directly (no TTY needed) - defaultAgent := agent.Default() - var stdout bytes.Buffer - err := runEnableWithStrategy(&stdout, []agent.Agent{defaultAgent}, "auto-commit", false, true, false, false, false, false) - if err != nil { - t.Fatalf("runEnableWithStrategy() error = %v", err) - } - - // Load the merged settings (project + local) - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - - // Strategy should be updated (from local) - if settings.Strategy != "auto-commit" { - t.Errorf("Strategy should be 'auto-commit', got %q", settings.Strategy) - } - - // strategy_options.push should be preserved - if settings.StrategyOptions == nil { - t.Fatal("strategy_options should be preserved, but got nil") - } - if settings.StrategyOptions["push"] != true { - t.Errorf("strategy_options.push should be true, got %v", settings.StrategyOptions["push"]) - } -} - // Tests for runUninstall and helper functions func TestRunUninstall_Force_NothingInstalled(t *testing.T) { diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index f2b098a7d..42a06be3e 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -16,6 +16,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/stringutil" "github.com/spf13/cobra" @@ -133,10 +134,7 @@ func runStatusDetailed(w io.Writer, sty statusStyles, settingsPath, localSetting // formatSettingsStatusShort formats a short settings status line. // Output format: "● Enabled · manual-commit · branch main" or "○ Disabled · auto-commit" func formatSettingsStatusShort(s *EntireSettings, sty statusStyles) string { - displayName := s.Strategy - if dn, ok := strategyInternalToDisplay[s.Strategy]; ok { - displayName = dn - } + displayName := strategy.StrategyNameManualCommit var b strings.Builder @@ -168,10 +166,7 @@ func formatSettingsStatusShort(s *EntireSettings, sty statusStyles) string { // formatSettingsStatus formats a settings status line with source prefix. // Output format: "Project · enabled · manual-commit" or "Local · disabled · auto-commit" func formatSettingsStatus(prefix string, s *EntireSettings, sty statusStyles) string { - displayName := s.Strategy - if dn, ok := strategyInternalToDisplay[s.Strategy]; ok { - displayName = dn - } + displayName := strategy.StrategyNameManualCommit var b strings.Builder b.WriteString(sty.render(sty.bold, prefix)) diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 2fcaaeef6..4c14ca3c0 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -252,7 +252,7 @@ func TestRunStatus_NotGitRepository(t *testing.T) { func TestRunStatus_LocalSettingsOnly(t *testing.T) { setupTestRepo(t) - writeLocalSettings(t, `{"strategy": "auto-commit", "enabled": true}`) + writeLocalSettings(t, `{"enabled": true}`) var stdout bytes.Buffer if err := runStatus(&stdout, true); err != nil { @@ -264,9 +264,6 @@ func TestRunStatus_LocalSettingsOnly(t *testing.T) { if !strings.Contains(output, "Enabled") { t.Errorf("Expected output to show 'Enabled', got: %s", output) } - if !strings.Contains(output, "auto-commit") { - t.Errorf("Expected output to show 'auto-commit', got: %s", output) - } // Should show per-file details if !strings.Contains(output, "Local") || !strings.Contains(output, "enabled") { t.Errorf("Expected output to show 'Local' and 'enabled', got: %s", output) @@ -279,10 +276,10 @@ func TestRunStatus_LocalSettingsOnly(t *testing.T) { func TestRunStatus_BothProjectAndLocal(t *testing.T) { setupTestRepo(t) // Project: enabled=true, strategy=manual-commit - // Local: enabled=false, strategy=auto-commit + // Local: enabled=false, strategy=manual-commit // Detailed mode shows effective status first, then each file separately - writeSettings(t, `{"strategy": "manual-commit", "enabled": true}`) - writeLocalSettings(t, `{"strategy": "auto-commit", "enabled": false}`) + writeSettings(t, `{"enabled": true}`) + writeLocalSettings(t, `{"enabled": false}`) var stdout bytes.Buffer if err := runStatus(&stdout, true); err != nil { @@ -306,10 +303,10 @@ func TestRunStatus_BothProjectAndLocal(t *testing.T) { func TestRunStatus_BothProjectAndLocal_Short(t *testing.T) { setupTestRepo(t) // Project: enabled=true, strategy=manual-commit - // Local: enabled=false, strategy=auto-commit + // Local: enabled=false, strategy=manual-commit // Short mode shows merged/effective settings - writeSettings(t, `{"strategy": "manual-commit", "enabled": true}`) - writeLocalSettings(t, `{"strategy": "auto-commit", "enabled": false}`) + writeSettings(t, `{"enabled": true}`) + writeLocalSettings(t, `{"enabled": false}`) var stdout bytes.Buffer if err := runStatus(&stdout, false); err != nil { @@ -323,24 +320,9 @@ func TestRunStatus_BothProjectAndLocal_Short(t *testing.T) { } } -func TestRunStatus_ShowsStrategy(t *testing.T) { - setupTestRepo(t) - writeSettings(t, `{"strategy": "auto-commit", "enabled": true}`) - - var stdout bytes.Buffer - if err := runStatus(&stdout, false); err != nil { - t.Fatalf("runStatus() error = %v", err) - } - - output := stdout.String() - if !strings.Contains(output, "auto-commit") { - t.Errorf("Expected output to show strategy 'auto-commit', got: %s", output) - } -} - func TestRunStatus_ShowsManualCommitStrategy(t *testing.T) { setupTestRepo(t) - writeSettings(t, `{"strategy": "manual-commit", "enabled": false}`) + writeSettings(t, `{"enabled": false}`) var stdout bytes.Buffer if err := runStatus(&stdout, true); err != nil { diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go deleted file mode 100644 index 0caddde0f..000000000 --- a/cmd/entire/cli/strategy/auto_commit.go +++ /dev/null @@ -1,1105 +0,0 @@ -package strategy - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/entireio/cli/cmd/entire/cli/agent" - "github.com/entireio/cli/cmd/entire/cli/buildinfo" - "github.com/entireio/cli/cmd/entire/cli/checkpoint" - "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" - "github.com/entireio/cli/cmd/entire/cli/logging" - "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/trailers" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" -) - -// isNotFoundError checks if an error represents a "not found" condition in go-git. -// This includes entry not found, file not found, directory not found, and object not found. -func isNotFoundError(err error) bool { - return errors.Is(err, object.ErrEntryNotFound) || - errors.Is(err, object.ErrFileNotFound) || - errors.Is(err, object.ErrDirectoryNotFound) || - errors.Is(err, plumbing.ErrObjectNotFound) || - errors.Is(err, plumbing.ErrReferenceNotFound) -} - -// 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. -func commitOrHead(repo *git.Repository, worktree *git.Worktree, msg string, author *object.Signature) (plumbing.Hash, error) { - 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") - head, err := repo.Head() - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get HEAD: %w", err) - } - return head.Hash(), nil - } - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to commit: %w", err) - } - return commitHash, 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) -// - Code commits can reference the shadow branch via trailers -type AutoCommitStrategy struct { - // checkpointStore manages checkpoint data on entire/checkpoints/v1 branch - checkpointStore *checkpoint.GitStore - // checkpointStoreOnce ensures thread-safe lazy initialization - checkpointStoreOnce sync.Once - // checkpointStoreErr captures any error during initialization - checkpointStoreErr error -} - -// getCheckpointStore returns the checkpoint store, initializing it lazily if needed. -// Thread-safe via sync.Once. -func (s *AutoCommitStrategy) getCheckpointStore() (*checkpoint.GitStore, error) { - s.checkpointStoreOnce.Do(func() { - repo, err := OpenRepository() - if err != nil { - s.checkpointStoreErr = fmt.Errorf("failed to open repository: %w", err) - return - } - s.checkpointStore = checkpoint.NewGitStore(repo) - }) - return s.checkpointStore, s.checkpointStoreErr -} - -// NewAutoCommitStrategy creates a new AutoCommitStrategy instance. -// - -func NewAutoCommitStrategy() Strategy { - return &AutoCommitStrategy{} -} - -func (s *AutoCommitStrategy) Name() string { - return StrategyNameAutoCommit -} - -func (s *AutoCommitStrategy) Description() string { - return "Auto-commits code to active branch with metadata on entire/checkpoints/v1" -} - -func (s *AutoCommitStrategy) ValidateRepository() error { - repo, err := OpenRepository() - if err != nil { - return fmt.Errorf("not a git repository: %w", err) - } - - _, err = repo.Worktree() - if err != nil { - return fmt.Errorf("failed to access worktree: %w", err) - } - - return nil -} - -// PrePush is called by the git pre-push hook before pushing to a remote. -// It pushes the entire/checkpoints/v1 branch alongside the user's push. -// Configuration options (stored in .entire/settings.json under strategy_options.push_sessions): -// - "auto": always push automatically -// - "prompt" (default): ask user with option to enable auto -// - "false"/"off"/"no": never push -func (s *AutoCommitStrategy) PrePush(remote string) error { - return pushSessionsBranchCommon(remote, paths.MetadataBranchName) -} - -func (s *AutoCommitStrategy) SaveStep(ctx StepContext) error { - repo, err := OpenRepository() - if err != nil { - return fmt.Errorf("failed to open git repository: %w", err) - } - - // Generate checkpoint ID for this commit - cpID, err := id.Generate() - if err != nil { - return fmt.Errorf("failed to generate checkpoint ID: %w", err) - } - // Step 1: Commit code changes to active branch with checkpoint ID trailer - // We do code first to avoid orphaned metadata if this step fails. - // If metadata commit fails after this, the code commit exists but GetRewindPoints - // already handles missing metadata gracefully (skips commits without metadata). - codeResult, err := s.commitCodeToActive(repo, ctx, cpID) - if err != nil { - return fmt.Errorf("failed to commit code to active branch: %w", err) - } - - // If no code commit was created (no changes), skip metadata creation - // This prevents orphaned metadata commits that don't correspond to any code commit - if !codeResult.Created { - logCtx := logging.WithComponent(context.Background(), "checkpoint") - logging.Info(logCtx, "checkpoint skipped (no changes)", - slog.String("strategy", "auto-commit"), - slog.String("checkpoint_type", "session"), - ) - fmt.Fprintf(os.Stderr, "Skipped checkpoint (no changes since last commit)\n") - return nil - } - - // Step 2: Commit metadata to entire/checkpoints/v1 branch using sharded path - // Path is // for direct lookup - _, err = s.commitMetadataToMetadataBranch(repo, ctx, cpID) - if err != nil { - return fmt.Errorf("failed to commit metadata to entire/checkpoints/v1 branch: %w", err) - } - - // Log checkpoint creation - logCtx := logging.WithComponent(context.Background(), "checkpoint") - logging.Info(logCtx, "checkpoint saved", - slog.String("strategy", "auto-commit"), - slog.String("checkpoint_type", "session"), - slog.String("checkpoint_id", cpID.String()), - slog.Int("modified_files", len(ctx.ModifiedFiles)), - slog.Int("new_files", len(ctx.NewFiles)), - slog.Int("deleted_files", len(ctx.DeletedFiles)), - ) - - return nil -} - -// commitCodeResult contains the result of committing code to the active branch. -type commitCodeResult struct { - CommitHash plumbing.Hash - Created bool // True if a new commit was created, false if skipped (no changes) -} - -// commitCodeToActive commits code changes to the active branch. -// Adds an Entire-Checkpoint trailer for metadata lookup that survives amend/rebase. -// Returns the result containing commit hash and whether a commit was created. -func (s *AutoCommitStrategy) commitCodeToActive(repo *git.Repository, ctx StepContext, checkpointID id.CheckpointID) (commitCodeResult, error) { - // Check if there are any code changes to commit - if len(ctx.ModifiedFiles) == 0 && len(ctx.NewFiles) == 0 && len(ctx.DeletedFiles) == 0 { - fmt.Fprintf(os.Stderr, "No code changes to commit to active branch\n") - // Return current HEAD hash but mark as not created - head, err := repo.Head() - if err != nil { - return commitCodeResult{}, fmt.Errorf("failed to get HEAD: %w", err) - } - return commitCodeResult{CommitHash: head.Hash(), Created: false}, nil - } - - worktree, err := repo.Worktree() - if err != nil { - return commitCodeResult{}, fmt.Errorf("failed to get worktree: %w", err) - } - - // Get HEAD hash before commit to detect if commitOrHead actually creates a new commit - // (commitOrHead returns HEAD hash without error when git.ErrEmptyCommit occurs) - headBefore, err := repo.Head() - if err != nil { - return commitCodeResult{}, fmt.Errorf("failed to get HEAD: %w", err) - } - - // Stage code changes - StageFiles(worktree, ctx.ModifiedFiles, ctx.NewFiles, ctx.DeletedFiles, StageForSession) - - // Add checkpoint ID trailer to commit message - commitMsg := ctx.CommitMessage + "\n\n" + trailers.CheckpointTrailerKey + ": " + checkpointID.String() - - author := &object.Signature{ - Name: ctx.AuthorName, - Email: ctx.AuthorEmail, - When: time.Now(), - } - commitHash, err := commitOrHead(repo, worktree, commitMsg, author) - if err != nil { - return commitCodeResult{}, err - } - - // Check if a new commit was actually created by comparing with HEAD before - created := commitHash != headBefore.Hash() - if created { - fmt.Fprintf(os.Stderr, "Committed code changes to active branch (%s)\n", commitHash.String()[:7]) - } - return commitCodeResult{CommitHash: commitHash, Created: created}, nil -} - -// commitMetadataToMetadataBranch commits session metadata to the entire/checkpoints/v1 branch. -// Metadata is stored at sharded path: // -// This allows direct lookup from the checkpoint ID trailer on the code commit. -// Uses checkpoint.WriteCommitted for git operations. -func (s *AutoCommitStrategy) commitMetadataToMetadataBranch(repo *git.Repository, ctx StepContext, checkpointID id.CheckpointID) (plumbing.Hash, error) { - store, err := s.getCheckpointStore() - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get checkpoint store: %w", err) - } - - // Extract session ID from metadata dir - sessionID := filepath.Base(ctx.MetadataDir) - - // Get current branch name - branchName := GetCurrentBranchName(repo) - - // Combine all file changes into FilesTouched (same as manual-commit) - filesTouched := mergeFilesTouched(nil, ctx.ModifiedFiles, ctx.NewFiles, ctx.DeletedFiles) - - // Load TurnID from session state (correlates checkpoints from the same turn) - var turnID string - if state, loadErr := LoadSessionState(sessionID); loadErr == nil && state != nil { - turnID = state.TurnID - } - - // Write committed checkpoint using the checkpoint store - // Pass TranscriptPath so writeTranscript generates content_hash.txt - transcriptPath := filepath.Join(ctx.MetadataDirAbs, paths.TranscriptFileName) - err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ - CheckpointID: checkpointID, - SessionID: sessionID, - Strategy: StrategyNameAutoCommit, // Use new strategy name - Branch: branchName, - MetadataDir: ctx.MetadataDirAbs, // Copy all files from metadata dir - TranscriptPath: transcriptPath, // For content hash generation - AuthorName: ctx.AuthorName, - AuthorEmail: ctx.AuthorEmail, - Agent: ctx.AgentType, - TurnID: turnID, - TranscriptIdentifierAtStart: ctx.StepTranscriptIdentifier, - CheckpointTranscriptStart: ctx.StepTranscriptStart, - TokenUsage: ctx.TokenUsage, - CheckpointsCount: 1, // Each auto-commit checkpoint = 1 - FilesTouched: filesTouched, // Track modified files (same as manual-commit) - }) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to write committed checkpoint: %w", err) - } - - fmt.Fprintf(os.Stderr, "Committed session metadata to %s (%s)\n", paths.MetadataBranchName, checkpointID) - return plumbing.ZeroHash, nil // Commit hash not needed by callers -} - -func (s *AutoCommitStrategy) GetRewindPoints(limit int) ([]RewindPoint, error) { - // For auto-commit strategy, rewind points are found by looking for Entire-Checkpoint trailers - // in the current branch's commit history. The checkpoint ID provides direct lookup - // to metadata on entire/checkpoints/v1 branch. - repo, err := OpenRepository() - if err != nil { - return nil, fmt.Errorf("failed to open git repository: %w", err) - } - - head, err := repo.Head() - if err != nil { - return nil, fmt.Errorf("failed to get HEAD: %w", err) - } - - // Get metadata branch tree for lookups - metadataTree, err := GetMetadataBranchTree(repo) - if err != nil { - // No metadata branch yet is fine - return []RewindPoint{}, nil //nolint:nilerr // Expected when no metadata exists - } - - // Get the main branch commit hash to determine branch-only commits - mainBranchHash := GetMainBranchHash(repo) - - // Walk current branch history looking for commits with checkpoint trailers - iter, err := repo.Log(&git.LogOptions{ - From: head.Hash(), - Order: git.LogOrderCommitterTime, - }) - if err != nil { - return nil, fmt.Errorf("failed to get commit log: %w", err) - } - - var points []RewindPoint - count := 0 - - err = iter.ForEach(func(c *object.Commit) error { - if count >= logsOnlyScanLimit || len(points) >= limit { - return errStop - } - count++ - - // Check for Entire-Checkpoint trailer - cpID, found := trailers.ParseCheckpoint(c.Message) - if !found { - return nil - } - - // Look up metadata from sharded path - checkpointPath := cpID.Path() - metadata, err := ReadCheckpointMetadata(metadataTree, checkpointPath) - if err != nil { - // Checkpoint exists in commit but no metadata found - skip this commit - return nil //nolint:nilerr // Intentional: skip commits without metadata - } - - message := strings.Split(c.Message, "\n")[0] - - // Determine if this is a full rewind or logs-only - // Full rewind is allowed if commit is only on this branch (not reachable from main) - isLogsOnly := false - if mainBranchHash != plumbing.ZeroHash { - if IsAncestorOf(repo, c.Hash, mainBranchHash) { - isLogsOnly = true - } - } - - // Build metadata path - for task checkpoints, include the task path - metadataDir := checkpointPath - if metadata.IsTask && metadata.ToolUseID != "" { - metadataDir = checkpointPath + "/tasks/" + metadata.ToolUseID - } - - // Read session prompt from metadata tree - sessionPrompt := ReadSessionPromptFromTree(metadataTree, checkpointPath) - - points = append(points, RewindPoint{ - ID: c.Hash.String(), - Message: message, - MetadataDir: metadataDir, - Date: c.Author.When, - IsLogsOnly: isLogsOnly, - CheckpointID: cpID, - IsTaskCheckpoint: metadata.IsTask, - ToolUseID: metadata.ToolUseID, - Agent: metadata.Agent, - SessionID: metadata.SessionID, - SessionPrompt: sessionPrompt, - }) - - return nil - }) - - if err != nil && !errors.Is(err, errStop) { - return nil, fmt.Errorf("failed to iterate commits: %w", err) - } - - return points, nil -} - -// findTaskMetadataPathForCommit looks up the task metadata path for a task checkpoint commit -// by searching the entire/checkpoints/v1 branch commit history for the checkpoint directory. -// Returns ("", nil) if metadata is not found - this is expected for commits without metadata. -func (s *AutoCommitStrategy) findTaskMetadataPathForCommit(repo *git.Repository, commitSHA, toolUseID string) (string, error) { - // Get the entire/checkpoints/v1 branch - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) - ref, err := repo.Reference(refName, true) - if err != nil { - if isNotFoundError(err) { - return "", nil // No metadata branch yet - } - return "", fmt.Errorf("failed to get metadata branch: %w", err) - } - - // Search commit history for a commit referencing this code commit SHA and tool use ID - shortSHA := commitSHA - if len(shortSHA) > 7 { - shortSHA = shortSHA[:7] - } - - iter, err := repo.Log(&git.LogOptions{From: ref.Hash()}) - if err != nil { - return "", fmt.Errorf("failed to get commit log: %w", err) - } - - var foundTaskPath string - err = iter.ForEach(func(commit *object.Commit) error { - // Check if commit message contains "Commit: " and the tool use ID - if strings.Contains(commit.Message, "Commit: "+shortSHA) && - strings.Contains(commit.Message, toolUseID) { - // Parse task metadata trailer - if taskPath, found := trailers.ParseTaskMetadata(commit.Message); found { - foundTaskPath = taskPath - return errStop // Found it - } - } - return nil - }) - if err != nil && !errors.Is(err, errStop) { - return "", fmt.Errorf("failed to iterate commits: %w", err) - } - - return foundTaskPath, nil -} - -func (s *AutoCommitStrategy) Rewind(point RewindPoint) error { - commitHash := plumbing.NewHash(point.ID) - shortID, err := HardResetWithProtection(commitHash) - if err != nil { - return err - } - - fmt.Println() - fmt.Printf("Reset to commit %s\n", shortID) - fmt.Println() - - return nil -} - -func (s *AutoCommitStrategy) CanRewind() (bool, string, error) { - return checkCanRewind() -} - -// PreviewRewind returns what will happen if rewinding to the given point. -// For auto-commit strategy, this returns nil since git reset doesn't delete untracked files. -func (s *AutoCommitStrategy) PreviewRewind(_ RewindPoint) (*RewindPreview, error) { - // Auto-commit uses git reset --hard which doesn't affect untracked files - // Return empty preview to indicate no untracked files will be deleted - return &RewindPreview{}, nil -} - -// EnsureSetup ensures the strategy's required setup is in place. -// For auto-commit strategy: -// - Ensure .entire/.gitignore has all required entries -// - Create orphan entire/checkpoints/v1 branch if it doesn't exist -// - Install git hooks if missing (self-healing for third-party overwrites) -func (s *AutoCommitStrategy) EnsureSetup() error { - if err := EnsureEntireGitignore(); err != nil { - return err - } - - repo, err := OpenRepository() - if err != nil { - return fmt.Errorf("failed to open git repository: %w", err) - } - - // Ensure the entire/checkpoints/v1 orphan branch exists - if err := EnsureMetadataBranch(repo); err != nil { - return fmt.Errorf("failed to ensure metadata branch: %w", err) - } - - // Install generic hooks if missing (they delegate to strategy at runtime) - if !IsGitHookInstalled() { - if _, err := InstallGitHook(true, isLocalDev()); err != nil { - return fmt.Errorf("failed to install git hooks: %w", err) - } - } - - return nil -} - -// GetSessionInfo returns session information for linking commits. -// For auto-commit strategy, we don't track active sessions - metadata is stored on -// entire/checkpoints/v1 branch when SaveStep is called. Active branch commits -// are kept clean (no trailers), so this returns ErrNoSession. -// Use ListSessions() or GetSession() to retrieve session info from the metadata branch. -func (s *AutoCommitStrategy) GetSessionInfo() (*SessionInfo, error) { - // Dual strategy doesn't track active sessions like shadow does. - // Session metadata is stored on entire/checkpoints/v1 branch and can be - // retrieved via ListSessions() or GetSession(). - return nil, ErrNoSession -} - -// SaveTaskStep creates a checkpoint commit for a completed task. -// For auto-commit strategy: -// 1. Commit code changes to active branch (no trailers - clean history) -// 2. Commit task metadata to entire/checkpoints/v1 branch with checkpoint format -func (s *AutoCommitStrategy) SaveTaskStep(ctx TaskStepContext) error { - repo, err := OpenRepository() - if err != nil { - return fmt.Errorf("failed to open git repository: %w", err) - } - - // Ensure entire/checkpoints/v1 branch exists - if err := EnsureMetadataBranch(repo); err != nil { - return fmt.Errorf("failed to ensure metadata branch: %w", err) - } - - // Generate checkpoint ID for this task checkpoint - cpID, err := id.Generate() - if err != nil { - return fmt.Errorf("failed to generate checkpoint ID: %w", err) - } - - // Step 1: Commit code changes to active branch with checkpoint ID trailer - // We do code first to avoid orphaned metadata if this step fails. - _, err = s.commitTaskCodeToActive(repo, ctx, cpID) - if err != nil { - return fmt.Errorf("failed to commit task code to active branch: %w", err) - } - - // Step 2: Commit task metadata to entire/checkpoints/v1 branch at sharded path - _, err = s.commitTaskMetadataToMetadataBranch(repo, ctx, cpID) - if err != nil { - return fmt.Errorf("failed to commit task metadata to entire/checkpoints/v1 branch: %w", err) - } - - // Log task checkpoint creation - logCtx := logging.WithComponent(context.Background(), "checkpoint") - attrs := []any{ - slog.String("strategy", "auto-commit"), - slog.String("checkpoint_type", "task"), - slog.String("checkpoint_id", cpID.String()), - slog.String("checkpoint_uuid", ctx.CheckpointUUID), - slog.String("tool_use_id", ctx.ToolUseID), - slog.String("subagent_type", ctx.SubagentType), - slog.Int("modified_files", len(ctx.ModifiedFiles)), - slog.Int("new_files", len(ctx.NewFiles)), - slog.Int("deleted_files", len(ctx.DeletedFiles)), - } - if ctx.IsIncremental { - attrs = append(attrs, - slog.Bool("is_incremental", true), - slog.String("incremental_type", ctx.IncrementalType), - slog.Int("incremental_sequence", ctx.IncrementalSequence), - ) - } - logging.Info(logCtx, "task checkpoint saved", attrs...) - - return nil -} - -// commitTaskCodeToActive commits task code changes to the active branch. -// Adds an Entire-Checkpoint trailer for metadata lookup that survives amend/rebase. -// Skips commit creation if there are no file changes. -func (s *AutoCommitStrategy) commitTaskCodeToActive(repo *git.Repository, ctx TaskStepContext, checkpointID id.CheckpointID) (plumbing.Hash, error) { - hasFileChanges := len(ctx.ModifiedFiles) > 0 || len(ctx.NewFiles) > 0 || len(ctx.DeletedFiles) > 0 - - // If no file changes, skip code commit - if !hasFileChanges { - fmt.Fprintf(os.Stderr, "No code changes to commit for task checkpoint\n") - // Return current HEAD hash so metadata can still be stored - head, err := repo.Head() - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get HEAD: %w", err) - } - return head.Hash(), nil - } - - worktree, err := repo.Worktree() - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get worktree: %w", err) - } - - // Stage code changes - StageFiles(worktree, ctx.ModifiedFiles, ctx.NewFiles, ctx.DeletedFiles, StageForTask) - - // Build commit message with checkpoint trailer - shortToolUseID := ctx.ToolUseID - if len(shortToolUseID) > id.ShortIDLength { - shortToolUseID = shortToolUseID[:id.ShortIDLength] - } - - var subject string - if ctx.IsIncremental { - subject = FormatIncrementalSubject( - ctx.IncrementalType, - ctx.SubagentType, - ctx.TaskDescription, - ctx.TodoContent, - ctx.IncrementalSequence, - shortToolUseID, - ) - } else { - subject = FormatSubagentEndMessage(ctx.SubagentType, ctx.TaskDescription, shortToolUseID) - } - - // Add checkpoint ID trailer to commit message - commitMsg := subject + "\n\n" + trailers.CheckpointTrailerKey + ": " + checkpointID.String() - - author := &object.Signature{ - Name: ctx.AuthorName, - Email: ctx.AuthorEmail, - When: time.Now(), - } - - commitHash, err := commitOrHead(repo, worktree, commitMsg, author) - if err != nil { - return plumbing.ZeroHash, err - } - - if ctx.IsIncremental { - fmt.Fprintf(os.Stderr, "Committed incremental checkpoint #%d to active branch (%s)\n", ctx.IncrementalSequence, commitHash.String()[:7]) - } else { - fmt.Fprintf(os.Stderr, "Committed task checkpoint to active branch (%s)\n", commitHash.String()[:7]) - } - return commitHash, nil -} - -// commitTaskMetadataToMetadataBranch commits task metadata to the entire/checkpoints/v1 branch. -// Uses sharded path: //tasks// -// Returns the metadata commit hash. -// When IsIncremental is true, only writes the incremental checkpoint file, skipping transcripts. -// Uses checkpoint.WriteCommitted for git operations. -func (s *AutoCommitStrategy) commitTaskMetadataToMetadataBranch(repo *git.Repository, ctx TaskStepContext, checkpointID id.CheckpointID) (plumbing.Hash, error) { - store, err := s.getCheckpointStore() - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to get checkpoint store: %w", err) - } - - // Format commit subject line for better git log readability - shortToolUseID := ctx.ToolUseID - if len(shortToolUseID) > id.ShortIDLength { - shortToolUseID = shortToolUseID[:id.ShortIDLength] - } - - var messageSubject string - if ctx.IsIncremental { - messageSubject = FormatIncrementalSubject( - ctx.IncrementalType, - ctx.SubagentType, - ctx.TaskDescription, - ctx.TodoContent, - ctx.IncrementalSequence, - shortToolUseID, - ) - } else { - messageSubject = FormatSubagentEndMessage(ctx.SubagentType, ctx.TaskDescription, shortToolUseID) - } - - // Get current branch name - branchName := GetCurrentBranchName(repo) - - // Write committed checkpoint using the checkpoint store - err = store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ - CheckpointID: checkpointID, - SessionID: ctx.SessionID, - Strategy: StrategyNameAutoCommit, - Branch: branchName, - IsTask: true, - ToolUseID: ctx.ToolUseID, - AgentID: ctx.AgentID, - CheckpointUUID: ctx.CheckpointUUID, - TranscriptPath: ctx.TranscriptPath, - SubagentTranscriptPath: ctx.SubagentTranscriptPath, - IsIncremental: ctx.IsIncremental, - IncrementalSequence: ctx.IncrementalSequence, - IncrementalType: ctx.IncrementalType, - IncrementalData: ctx.IncrementalData, - CommitSubject: messageSubject, - AuthorName: ctx.AuthorName, - AuthorEmail: ctx.AuthorEmail, - Agent: ctx.AgentType, - }) - if err != nil { - return plumbing.ZeroHash, fmt.Errorf("failed to write task checkpoint: %w", err) - } - - if ctx.IsIncremental { - fmt.Fprintf(os.Stderr, "Committed incremental checkpoint metadata to %s (%s)\n", paths.MetadataBranchName, checkpointID) - } else { - fmt.Fprintf(os.Stderr, "Committed task metadata to %s (%s)\n", paths.MetadataBranchName, checkpointID) - } - return plumbing.ZeroHash, nil // Commit hash not needed by callers -} - -// GetTaskCheckpoint returns the task checkpoint for a given rewind point. -// For auto-commit strategy, checkpoints are stored on the entire/checkpoints/v1 branch in checkpoint directories. -// Returns ErrNotTaskCheckpoint if the point is not a task checkpoint. -func (s *AutoCommitStrategy) GetTaskCheckpoint(point RewindPoint) (*TaskCheckpoint, error) { - if !point.IsTaskCheckpoint { - return nil, ErrNotTaskCheckpoint - } - - repo, err := OpenRepository() - if err != nil { - return nil, fmt.Errorf("failed to open repository: %w", err) - } - - // Get the entire/checkpoints/v1 branch - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) - ref, err := repo.Reference(refName, true) - if err != nil { - return nil, fmt.Errorf("metadata branch %s not found: %w", paths.MetadataBranchName, err) - } - - metadataCommit, err := repo.CommitObject(ref.Hash()) - if err != nil { - return nil, fmt.Errorf("failed to get metadata branch commit: %w", err) - } - - tree, err := metadataCommit.Tree() - if err != nil { - return nil, fmt.Errorf("failed to get metadata tree: %w", err) - } - - // Find checkpoint using the metadata path from rewind point - // MetadataDir for auto-commit task checkpoints is: cond-YYYYMMDD-HHMMSS-XXXXXXXX/tasks/ - checkpointPath := point.MetadataDir + "/checkpoint.json" - file, err := tree.File(checkpointPath) - if err != nil { - // Try finding via commit SHA lookup - taskCheckpointPath, findErr := s.findTaskCheckpointPath(repo, point.ID, point.ToolUseID) - if findErr != nil { - return nil, fmt.Errorf("failed to find checkpoint at %s: %w", checkpointPath, err) - } - file, err = tree.File(taskCheckpointPath) - if err != nil { - return nil, fmt.Errorf("failed to find checkpoint at %s: %w", taskCheckpointPath, err) - } - } - - content, err := file.Contents() - if err != nil { - return nil, fmt.Errorf("failed to read checkpoint: %w", err) - } - - var checkpoint TaskCheckpoint - if err := json.Unmarshal([]byte(content), &checkpoint); err != nil { - return nil, fmt.Errorf("failed to parse checkpoint: %w", err) - } - - return &checkpoint, nil -} - -// GetTaskCheckpointTranscript returns the session transcript for a task checkpoint. -// For auto-commit strategy, transcripts are stored on the entire/checkpoints/v1 branch in checkpoint directories. -// Returns ErrNotTaskCheckpoint if the point is not a task checkpoint. -func (s *AutoCommitStrategy) GetTaskCheckpointTranscript(point RewindPoint) ([]byte, error) { - if !point.IsTaskCheckpoint { - return nil, ErrNotTaskCheckpoint - } - - repo, err := OpenRepository() - if err != nil { - return nil, fmt.Errorf("failed to open repository: %w", err) - } - - // Get the entire/checkpoints/v1 branch - refName := plumbing.NewBranchReferenceName(paths.MetadataBranchName) - ref, err := repo.Reference(refName, true) - if err != nil { - return nil, fmt.Errorf("metadata branch %s not found: %w", paths.MetadataBranchName, err) - } - - metadataCommit, err := repo.CommitObject(ref.Hash()) - if err != nil { - return nil, fmt.Errorf("failed to get metadata branch commit: %w", err) - } - - tree, err := metadataCommit.Tree() - if err != nil { - return nil, fmt.Errorf("failed to get metadata tree: %w", err) - } - - // MetadataDir for auto-commit task checkpoints is: //tasks/ - // Extract the checkpoint path by removing "/tasks/" - metadataDir := point.MetadataDir - if idx := strings.Index(metadataDir, "/tasks/"); idx > 0 { - checkpointPath := metadataDir[:idx] - - // Use the first session's transcript path from sessions array - transcriptPath := "" - summaryFile, summaryErr := tree.File(checkpointPath + "/" + paths.MetadataFileName) - if summaryErr == nil { - summaryContent, contentErr := summaryFile.Contents() - if contentErr == nil { - var summary checkpoint.CheckpointSummary - if json.Unmarshal([]byte(summaryContent), &summary) == nil && len(summary.Sessions) > 0 { - // Use first session's transcript path (task checkpoints have only one session) - // SessionFilePaths now contains absolute paths with leading "/" - // Strip the leading "/" for tree.File() which expects paths without leading slash - if summary.Sessions[0].Transcript != "" { - transcriptPath = strings.TrimPrefix(summary.Sessions[0].Transcript, "/") - } - } - } - } - - // Fall back to old format if sessions map not available - if transcriptPath == "" { - transcriptPath = checkpointPath + "/" + paths.TranscriptFileName - } - - file, err := tree.File(transcriptPath) - if err != nil { - return nil, fmt.Errorf("failed to find transcript at %s: %w", transcriptPath, err) - } - content, err := file.Contents() - if err != nil { - return nil, fmt.Errorf("failed to read transcript: %w", err) - } - return []byte(content), nil - } - - return nil, fmt.Errorf("invalid metadata path format: %s", metadataDir) -} - -// findTaskCheckpointPath finds the full path to a task checkpoint on the entire/checkpoints/v1 branch. -// Searches checkpoint directories for the task checkpoint matching the commit SHA and tool use ID. -func (s *AutoCommitStrategy) findTaskCheckpointPath(repo *git.Repository, commitSHA, toolUseID string) (string, error) { - // Use findTaskMetadataPathForCommit which searches commit history - taskPath, err := s.findTaskMetadataPathForCommit(repo, commitSHA, toolUseID) - if err != nil { - return "", err - } - if taskPath == "" { - return "", errors.New("task checkpoint not found") - } - // taskPath is like: cond-YYYYMMDD-HHMMSS-XXXXXXXX/tasks//checkpoints/001-.json - // We need: cond-YYYYMMDD-HHMMSS-XXXXXXXX/tasks//checkpoint.json - if idx := strings.Index(taskPath, "/checkpoints/"); idx > 0 { - return taskPath[:idx] + "/checkpoint.json", nil - } - return taskPath + "/checkpoint.json", nil -} - -// GetMetadataRef returns a reference to the metadata for the given checkpoint. -// For auto-commit strategy, returns the checkpoint path on entire/checkpoints/v1 branch. -func (s *AutoCommitStrategy) GetMetadataRef(checkpoint Checkpoint) string { - if checkpoint.CheckpointID.IsEmpty() { - return "" - } - return paths.MetadataBranchName + ":" + checkpoint.CheckpointID.Path() -} - -// GetSessionMetadataRef returns a reference to the most recent metadata for a session. -func (s *AutoCommitStrategy) GetSessionMetadataRef(sessionID string) string { - session, err := GetSession(sessionID) - if err != nil || len(session.Checkpoints) == 0 { - return "" - } - // Checkpoints are ordered with most recent first - return s.GetMetadataRef(session.Checkpoints[0]) -} - -// GetSessionContext returns the context.md content for a session. -// For auto-commit strategy, reads from the entire/checkpoints/v1 branch using the checkpoint store. -func (s *AutoCommitStrategy) GetSessionContext(sessionID string) string { - session, err := GetSession(sessionID) - if err != nil || len(session.Checkpoints) == 0 { - return "" - } - - // Get the most recent checkpoint - cp := session.Checkpoints[0] - if cp.CheckpointID.IsEmpty() { - return "" - } - - store, err := s.getCheckpointStore() - if err != nil { - return "" - } - - content, err := store.ReadSessionContentByID(context.Background(), cp.CheckpointID, sessionID) - if err != nil || content == nil { - return "" - } - - return content.Context -} - -// GetCheckpointLog returns the session transcript for a specific checkpoint. -// For auto-commit strategy, looks up checkpoint by ID on the entire/checkpoints/v1 branch using the checkpoint store. -func (s *AutoCommitStrategy) GetCheckpointLog(cp Checkpoint) ([]byte, error) { - if cp.CheckpointID.IsEmpty() { - return nil, ErrNoMetadata - } - - store, err := s.getCheckpointStore() - if err != nil { - return nil, fmt.Errorf("failed to get checkpoint store: %w", err) - } - - content, err := store.ReadLatestSessionContent(context.Background(), cp.CheckpointID) - if err != nil { - return nil, fmt.Errorf("failed to read checkpoint: %w", err) - } - if content == nil { - return nil, ErrNoMetadata - } - - return content.Transcript, nil -} - -// InitializeSession creates session state for a new session. -// This is called during UserPromptSubmit hook to set up tracking for the session. -// For auto-commit strategy, this creates a SessionState file in .git/entire-sessions/ -// to track CheckpointTranscriptStart (transcript offset) across checkpoints. -// agentType is the human-readable name of the agent (e.g., "Claude Code"). -// transcriptPath is the path to the live transcript file (for mid-session commit detection). -// userPrompt is the user's prompt text (stored truncated as FirstPrompt for display). -func (s *AutoCommitStrategy) InitializeSession(sessionID string, agentType agent.AgentType, transcriptPath string, userPrompt string) error { - repo, err := OpenRepository() - if err != nil { - return fmt.Errorf("failed to open git repository: %w", err) - } - - // Get current HEAD commit to track as base - head, err := repo.Head() - if err != nil { - return fmt.Errorf("failed to get HEAD: %w", err) - } - - baseCommit := head.Hash().String() - - // Check if session state already exists (e.g., session resuming) - existing, err := LoadSessionState(sessionID) - if err != nil { - return fmt.Errorf("failed to check existing session state: %w", err) - } - if existing != nil { - // Session already initialized — update last interaction time on every prompt submit - now := time.Now() - existing.LastInteractionTime = &now - - // Generate a new TurnID for each turn (correlates carry-forward checkpoints) - turnID, err := id.Generate() - if err != nil { - return fmt.Errorf("failed to generate turn ID: %w", err) - } - existing.TurnID = turnID.String() - existing.TurnCheckpointIDs = nil - - // Backfill FirstPrompt if empty (for sessions - // created before the first_prompt field was added, or resumed sessions) - if existing.FirstPrompt == "" && userPrompt != "" { - existing.FirstPrompt = truncatePromptForStorage(userPrompt) - } - - if err := SaveSessionState(existing); err != nil { - return fmt.Errorf("failed to update session state: %w", err) - } - return nil - } - - // Generate TurnID for the first turn - turnID, err := id.Generate() - if err != nil { - return fmt.Errorf("failed to generate turn ID: %w", err) - } - - // Create new session state - now := time.Now() - state := &SessionState{ - SessionID: sessionID, - CLIVersion: buildinfo.Version, - BaseCommit: baseCommit, - StartedAt: now, - LastInteractionTime: &now, - TurnID: turnID.String(), - StepCount: 0, - // CheckpointTranscriptStart defaults to 0 (start from beginning of transcript) - FilesTouched: []string{}, - AgentType: agentType, - TranscriptPath: transcriptPath, - FirstPrompt: truncatePromptForStorage(userPrompt), - } - - if err := SaveSessionState(state); err != nil { - return fmt.Errorf("failed to save session state: %w", err) - } - - return nil -} - -// ListOrphanedItems returns orphaned items created by the auto-commit strategy. -// For auto-commit, checkpoints are orphaned when no commit has an Entire-Checkpoint -// trailer referencing them (e.g., after rebasing or squashing). -func (s *AutoCommitStrategy) ListOrphanedItems() ([]CleanupItem, error) { - repo, err := OpenRepository() - if err != nil { - return nil, fmt.Errorf("failed to open repository: %w", err) - } - - // Get checkpoint store (lazily initialized) - cpStore, err := s.getCheckpointStore() - if err != nil { - return nil, fmt.Errorf("failed to get checkpoint store: %w", err) - } - - // Get all checkpoints from entire/checkpoints/v1 branch - checkpoints, err := cpStore.ListCommitted(context.Background()) - if err != nil { - return []CleanupItem{}, nil //nolint:nilerr // No checkpoints is not an error for cleanup - } - - if len(checkpoints) == 0 { - return []CleanupItem{}, nil - } - - // Filter to only auto-commit checkpoints (identified by strategy in metadata) - autoCommitCheckpoints := make(map[string]bool) - for _, cp := range checkpoints { - summary, readErr := cpStore.ReadCommitted(context.Background(), cp.CheckpointID) - if readErr != nil || summary == nil { - continue - } - // Only consider checkpoints created by this strategy - if summary.Strategy == StrategyNameAutoCommit { - autoCommitCheckpoints[cp.CheckpointID.String()] = true - } - } - - if len(autoCommitCheckpoints) == 0 { - return []CleanupItem{}, nil - } - - // Find checkpoint IDs referenced in commits - referencedCheckpoints := s.findReferencedCheckpoints(repo) - - // Find orphaned checkpoints - var items []CleanupItem - for checkpointID := range autoCommitCheckpoints { - if !referencedCheckpoints[checkpointID] { - items = append(items, CleanupItem{ - Type: CleanupTypeCheckpoint, - ID: checkpointID, - Reason: "no commit references this checkpoint", - }) - } - } - - return items, nil -} - -// findReferencedCheckpoints scans commits for Entire-Checkpoint trailers. -func (s *AutoCommitStrategy) findReferencedCheckpoints(repo *git.Repository) map[string]bool { - referenced := make(map[string]bool) - - refs, err := repo.References() - if err != nil { - return referenced - } - - visited := make(map[plumbing.Hash]bool) - - _ = refs.ForEach(func(ref *plumbing.Reference) error { //nolint:errcheck // Best effort - if !ref.Name().IsBranch() { - return nil - } - // Skip entire/* branches - branchName := strings.TrimPrefix(ref.Name().String(), "refs/heads/") - if strings.HasPrefix(branchName, "entire/") { - return nil - } - - iter, iterErr := repo.Log(&git.LogOptions{From: ref.Hash()}) - if iterErr != nil { - return nil //nolint:nilerr // Best effort - } - - count := 0 - _ = iter.ForEach(func(c *object.Commit) error { //nolint:errcheck // Best effort - count++ - if count > 1000 { - return errors.New("limit reached") - } - if visited[c.Hash] { - return nil - } - visited[c.Hash] = true - - if cpID, found := trailers.ParseCheckpoint(c.Message); found { - referenced[cpID.String()] = true - } - return nil - }) - return nil - }) - - return referenced -} - -//nolint:gochecknoinits // Standard pattern for strategy registration -func init() { - // Register auto-commit as the primary strategy name - Register(StrategyNameAutoCommit, NewAutoCommitStrategy) -} diff --git a/cmd/entire/cli/strategy/auto_commit_test.go b/cmd/entire/cli/strategy/auto_commit_test.go deleted file mode 100644 index 0a66c0207..000000000 --- a/cmd/entire/cli/strategy/auto_commit_test.go +++ /dev/null @@ -1,1037 +0,0 @@ -package strategy - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/trailers" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" -) - -func TestAutoCommitStrategy_Registration(t *testing.T) { - s, err := Get(StrategyNameAutoCommit) - if err != nil { - t.Fatalf("Get(%q) error = %v", StrategyNameAutoCommit, err) - } - if s == nil { - t.Fatal("Get() returned nil strategy") - } - if s.Name() != StrategyNameAutoCommit { - t.Errorf("Name() = %q, want %q", s.Name(), StrategyNameAutoCommit) - } -} - -func TestAutoCommitStrategy_SaveStep_CommitHasMetadataRef(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy and ensure entire/checkpoints/v1 branch exists - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create a modified file - 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) - } - - // Create metadata directory with session log - sessionID := "2025-12-04-test-session-123" - metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) - if err := os.MkdirAll(metadataDir, 0o750); err != nil { - t.Fatalf("failed to create metadata dir: %v", err) - } - logFile := filepath.Join(metadataDir, paths.TranscriptFileName) - if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { - t.Fatalf("failed to write log file: %v", err) - } - - metadataDirAbs, err := paths.AbsPath(metadataDir) - if err != nil { - metadataDirAbs = metadataDir - } - - // Call SaveStep - ctx := StepContext{ - CommitMessage: "Test session commit", - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, - NewFiles: []string{"test.go"}, - ModifiedFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - - if err := s.SaveStep(ctx); err != nil { - t.Fatalf("SaveStep() error = %v", err) - } - - // Verify the code commit on active branch has NO trailers (clean history) - head, err := repo.Head() - if err != nil { - t.Fatalf("failed to get HEAD: %v", err) - } - - commit, err := repo.CommitObject(head.Hash()) - if err != nil { - t.Fatalf("failed to get HEAD commit: %v", err) - } - - // Active branch commits should be clean - no Entire-* trailers - if strings.Contains(commit.Message, trailers.StrategyTrailerKey) { - t.Errorf("code commit should NOT have strategy trailer, got message:\n%s", commit.Message) - } - if strings.Contains(commit.Message, trailers.SourceRefTrailerKey) { - t.Errorf("code commit should NOT have source-ref trailer, got message:\n%s", commit.Message) - } - if strings.Contains(commit.Message, trailers.SessionTrailerKey) { - t.Errorf("code commit should NOT have session trailer, got message:\n%s", commit.Message) - } - - // Verify metadata was stored on entire/checkpoints/v1 branch - sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("entire/checkpoints/v1 branch not found: %v", err) - } - sessionsCommit, err := repo.CommitObject(sessionsRef.Hash()) - if err != nil { - t.Fatalf("failed to get sessions branch commit: %v", err) - } - - // Metadata commit should have the checkpoint format with session ID and strategy - if !strings.Contains(sessionsCommit.Message, trailers.SessionTrailerKey) { - t.Errorf("sessions branch commit should have session trailer, got message:\n%s", sessionsCommit.Message) - } - if !strings.Contains(sessionsCommit.Message, trailers.StrategyTrailerKey) { - t.Errorf("sessions branch commit should have strategy trailer, got message:\n%s", sessionsCommit.Message) - } -} - -func TestAutoCommitStrategy_SaveStep_MetadataRefPointsToValidCommit(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create a modified file - 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) - } - - // Create metadata directory - sessionID := "2025-12-04-test-session-456" - metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) - if err := os.MkdirAll(metadataDir, 0o750); err != nil { - t.Fatalf("failed to create metadata dir: %v", err) - } - logFile := filepath.Join(metadataDir, paths.TranscriptFileName) - if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { - t.Fatalf("failed to write log file: %v", err) - } - - metadataDirAbs, err := paths.AbsPath(metadataDir) - if err != nil { - metadataDirAbs = metadataDir - } - - // Call SaveStep - ctx := StepContext{ - CommitMessage: "Test session commit", - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, - NewFiles: []string{"test.go"}, - ModifiedFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - - if err := s.SaveStep(ctx); err != nil { - t.Fatalf("SaveStep() error = %v", err) - } - - // Get the code commit - head, err := repo.Head() - if err != nil { - t.Fatalf("failed to get HEAD: %v", err) - } - commit, err := repo.CommitObject(head.Hash()) - if err != nil { - t.Fatalf("failed to get HEAD commit: %v", err) - } - - // Code commit should be clean - no Entire-* trailers - if strings.Contains(commit.Message, trailers.SourceRefTrailerKey) { - t.Errorf("code commit should NOT have source-ref trailer, got:\n%s", commit.Message) - } - - // Get the entire/checkpoints/v1 branch - metadataBranchRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("failed to get entire/checkpoints/v1 branch: %v", err) - } - - metadataCommit, err := repo.CommitObject(metadataBranchRef.Hash()) - if err != nil { - t.Fatalf("failed to get metadata branch commit: %v", err) - } - - // Verify the metadata commit has the checkpoint format - if !strings.HasPrefix(metadataCommit.Message, "Checkpoint: ") { - t.Errorf("metadata commit missing checkpoint format, got:\n%s", metadataCommit.Message) - } - - // Verify it contains the session ID - if !strings.Contains(metadataCommit.Message, trailers.SessionTrailerKey+": "+sessionID) { - t.Errorf("metadata commit missing %s trailer for %s", trailers.SessionTrailerKey, sessionID) - } - - // Verify it contains the strategy (auto-commit) - if !strings.Contains(metadataCommit.Message, trailers.StrategyTrailerKey+": "+StrategyNameAutoCommit) { - t.Errorf("metadata commit missing %s trailer for %s", trailers.StrategyTrailerKey, StrategyNameAutoCommit) - } -} - -func TestAutoCommitStrategy_SaveTaskStep_CommitHasMetadataRef(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create a file (simulating task output) - testFile := filepath.Join(dir, "task_output.txt") - if err := os.WriteFile(testFile, []byte("task result"), 0o644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - // Create transcript file - transcriptDir := t.TempDir() - transcriptPath := filepath.Join(transcriptDir, "session.jsonl") - if err := os.WriteFile(transcriptPath, []byte(`{"type":"test"}`), 0o644); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } - - // Call SaveTaskStep - ctx := TaskStepContext{ - SessionID: "test-session-789", - ToolUseID: "toolu_abc123", - CheckpointUUID: "checkpoint-uuid-456", - AgentID: "agent-xyz", - TranscriptPath: transcriptPath, - NewFiles: []string{"task_output.txt"}, - ModifiedFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - - if err := s.SaveTaskStep(ctx); err != nil { - t.Fatalf("SaveTaskStep() error = %v", err) - } - - // Verify the code commit is clean (no trailers) - head, err := repo.Head() - if err != nil { - t.Fatalf("failed to get HEAD: %v", err) - } - - commit, err := repo.CommitObject(head.Hash()) - if err != nil { - t.Fatalf("failed to get HEAD commit: %v", err) - } - - // Task checkpoint commit should be clean - no Entire-* trailers - if strings.Contains(commit.Message, trailers.SourceRefTrailerKey) { - t.Errorf("task checkpoint commit should NOT have source-ref trailer, got message:\n%s", commit.Message) - } - if strings.Contains(commit.Message, trailers.StrategyTrailerKey) { - t.Errorf("task checkpoint commit should NOT have strategy trailer, got message:\n%s", commit.Message) - } - - // Verify metadata was stored on entire/checkpoints/v1 branch - sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("entire/checkpoints/v1 branch not found: %v", err) - } - sessionsCommit, err := repo.CommitObject(sessionsRef.Hash()) - if err != nil { - t.Fatalf("failed to get sessions branch commit: %v", err) - } - - // Metadata commit should reference the checkpoint - if !strings.Contains(sessionsCommit.Message, "Checkpoint: ") { - t.Errorf("sessions branch commit missing checkpoint format, got:\n%s", sessionsCommit.Message) - } -} - -func TestAutoCommitStrategy_SaveTaskStep_NoChangesSkipsCommit(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create an incremental checkpoint with NO file changes - ctx := TaskStepContext{ - SessionID: "test-session-nochanges", - ToolUseID: "toolu_nochanges456", - IsIncremental: true, - IncrementalType: "TodoWrite", - IncrementalSequence: 2, - TodoContent: "Write some code", - // No file changes - ModifiedFiles: []string{}, - NewFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - - if err := s.SaveTaskStep(ctx); err != nil { - t.Fatalf("SaveTaskStep() error = %v", err) - } - - // Get HEAD after the operation - head, err := repo.Head() - if err != nil { - t.Fatalf("failed to get HEAD: %v", err) - } - - // The auto-commit strategy amends the HEAD commit to add source ref trailer, - // so HEAD will be different from the initial commit even without file changes. - // However, the commit tree should be the same as the initial commit. - newCommit, err := repo.CommitObject(head.Hash()) - if err != nil { - t.Fatalf("failed to get HEAD commit: %v", err) - } - - oldCommit, err := repo.CommitObject(initialCommit) - if err != nil { - t.Fatalf("failed to get initial commit: %v", err) - } - - // The tree hash should be the same (no file changes) - if newCommit.TreeHash != oldCommit.TreeHash { - t.Error("checkpoint without file changes should have the same tree hash") - } - - // Metadata should still be stored on entire/checkpoints/v1 branch - metadataBranch, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("failed to get entire/checkpoints/v1 branch: %v", err) - } - - metadataCommit, err := repo.CommitObject(metadataBranch.Hash()) - if err != nil { - t.Fatalf("failed to get metadata commit: %v", err) - } - - // Verify metadata was committed to the branch - if !strings.Contains(metadataCommit.Message, trailers.MetadataTaskTrailerKey) { - t.Error("metadata should still be committed to entire/checkpoints/v1 branch") - } -} - -func TestAutoCommitStrategy_GetSessionContext(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create a modified file - 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) - } - - // Create metadata directory with session log and context.md - sessionID := "2025-12-10-test-session-context" - metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) - if err := os.MkdirAll(metadataDir, 0o750); err != nil { - t.Fatalf("failed to create metadata dir: %v", err) - } - logFile := filepath.Join(metadataDir, paths.TranscriptFileName) - if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { - t.Fatalf("failed to write log file: %v", err) - } - contextContent := "# Session Context\n\nThis is a test context.\n\n## Details\n\n- Item 1\n- Item 2" - contextFile := filepath.Join(metadataDir, paths.ContextFileName) - if err := os.WriteFile(contextFile, []byte(contextContent), 0o644); err != nil { - t.Fatalf("failed to write context file: %v", err) - } - - metadataDirAbs, err := paths.AbsPath(metadataDir) - if err != nil { - metadataDirAbs = metadataDir - } - - // Save changes - this creates a checkpoint on entire/checkpoints/v1 - ctx := StepContext{ - CommitMessage: "Test checkpoint", - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, - NewFiles: []string{"test.go"}, - ModifiedFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - if err := s.SaveStep(ctx); err != nil { - t.Fatalf("SaveStep() error = %v", err) - } - - // Now retrieve the context using GetSessionContext - result := s.GetSessionContext(sessionID) - if result == "" { - t.Error("GetSessionContext() returned empty string") - } - if result != contextContent { - t.Errorf("GetSessionContext() = %q, want %q", result, contextContent) - } -} - -func TestAutoCommitStrategy_ListSessions_HasDescription(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create a modified file - 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) - } - - // Create metadata directory with session log and prompt.txt - sessionID := "2025-12-10-test-session-description" - metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) - if err := os.MkdirAll(metadataDir, 0o750); err != nil { - t.Fatalf("failed to create metadata dir: %v", err) - } - logFile := filepath.Join(metadataDir, paths.TranscriptFileName) - if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { - t.Fatalf("failed to write log file: %v", err) - } - - // Write prompt.txt with description - expectedDescription := "Fix the authentication bug in login.go" - promptFile := filepath.Join(metadataDir, paths.PromptFileName) - if err := os.WriteFile(promptFile, []byte(expectedDescription+"\n\nMore details here..."), 0o644); err != nil { - t.Fatalf("failed to write prompt file: %v", err) - } - - metadataDirAbs, err := paths.AbsPath(metadataDir) - if err != nil { - metadataDirAbs = metadataDir - } - - // Save changes - this creates a checkpoint on entire/checkpoints/v1 - ctx := StepContext{ - CommitMessage: "Test checkpoint", - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, - NewFiles: []string{"test.go"}, - ModifiedFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - SessionID: sessionID, - } - if err := s.SaveStep(ctx); err != nil { - t.Fatalf("SaveStep() error = %v", err) - } - - sessions, err := ListSessions() - if err != nil { - t.Fatalf("ListSessions() error = %v", err) - } - - if len(sessions) == 0 { - t.Fatal("ListSessions() returned no sessions") - } - - // Find our session - var found *Session - for i := range sessions { - if sessions[i].ID == sessionID { - found = &sessions[i] - break - } - } - - if found == nil { - t.Fatalf("Session %q not found in ListSessions() result", sessionID) - } - - // Verify description is populated (not "No description") - if found.Description == NoDescription { - t.Errorf("ListSessions() returned session with Description = %q, want %q", found.Description, expectedDescription) - } - if found.Description != expectedDescription { - t.Errorf("ListSessions() returned session with Description = %q, want %q", found.Description, expectedDescription) - } -} - -// TestAutoCommitStrategy_ImplementsSessionInitializer verifies that AutoCommitStrategy -// implements the SessionInitializer interface for session state management. -func TestAutoCommitStrategy_ImplementsSessionInitializer(t *testing.T) { - s := NewAutoCommitStrategy() - - // Verify it implements SessionInitializer - _, ok := s.(SessionInitializer) - if !ok { - t.Fatal("AutoCommitStrategy should implement SessionInitializer interface") - } -} - -// TestAutoCommitStrategy_InitializeSession_CreatesSessionState verifies that -// InitializeSession creates a SessionState file for auto-commit strategy. -func TestAutoCommitStrategy_InitializeSession_CreatesSessionState(t *testing.T) { - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - s := NewAutoCommitStrategy() - initializer, ok := s.(SessionInitializer) - if !ok { - t.Fatal("AutoCommitStrategy should implement SessionInitializer") - } - - sessionID := "2025-12-22-test-session-init" - if err := initializer.InitializeSession(sessionID, "Claude Code", "", ""); err != nil { - t.Fatalf("InitializeSession() error = %v", err) - } - - // Verify session state was created - state, err := LoadSessionState(sessionID) - if err != nil { - t.Fatalf("LoadSessionState() error = %v", err) - } - if state == nil { - t.Fatal("SessionState not created") - } - - if state.SessionID != sessionID { - t.Errorf("SessionID = %q, want %q", state.SessionID, sessionID) - } - if state.StepCount != 0 { - t.Errorf("StepCount = %d, want 0", state.StepCount) - } - if state.CheckpointTranscriptStart != 0 { - t.Errorf("CheckpointTranscriptStart = %d, want 0", state.CheckpointTranscriptStart) - } -} - -func TestAutoCommitStrategy_GetCheckpointLog_ReadsFullJsonl(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create a file for the task checkpoint - testFile := filepath.Join(dir, "task_output.txt") - if err := os.WriteFile(testFile, []byte("task result"), 0o644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - // Create transcript file with expected content - transcriptDir := t.TempDir() - transcriptPath := filepath.Join(transcriptDir, "session.jsonl") - expectedContent := `{"type":"assistant","content":"test response"}` - if err := os.WriteFile(transcriptPath, []byte(expectedContent), 0o644); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } - - sessionID := "2025-12-12-test-checkpoint-jsonl" - - // Call SaveTaskStep (final, not incremental - this includes full.jsonl) - ctx := TaskStepContext{ - SessionID: sessionID, - ToolUseID: "toolu_jsonl_test", - CheckpointUUID: "checkpoint-uuid-jsonl", - AgentID: "agent-jsonl", - TranscriptPath: transcriptPath, - NewFiles: []string{"task_output.txt"}, - ModifiedFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - - if err := s.SaveTaskStep(ctx); err != nil { - t.Fatalf("SaveTaskStep() error = %v", err) - } - - sessions, err := ListSessions() - if err != nil { - t.Fatalf("ListSessions() error = %v", err) - } - - var session *Session - for i := range sessions { - if sessions[i].ID == sessionID { - session = &sessions[i] - break - } - } - if session == nil { - t.Fatalf("Session %q not found", sessionID) - } - if len(session.Checkpoints) == 0 { - t.Fatal("No checkpoints found for session") - } - - // Get checkpoint log - should read full.jsonl - checkpoint := session.Checkpoints[0] - content, err := s.GetCheckpointLog(checkpoint) - if err != nil { - t.Fatalf("GetCheckpointLog() error = %v", err) - } - - if string(content) != expectedContent { - t.Errorf("GetCheckpointLog() content = %q, want %q", string(content), expectedContent) - } -} - -// TestAutoCommitStrategy_SaveStep_FilesAlreadyCommitted verifies that SaveStep -// skips creating metadata when files are listed but already committed by the user. -// This handles the case where git.ErrEmptyCommit occurs during commit. -func TestAutoCommitStrategy_SaveStep_FilesAlreadyCommitted(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - _, err = worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Create a test file and commit it manually (simulating user committing before hook runs) - 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) - } - if _, err := worktree.Add("test.go"); err != nil { - t.Fatalf("failed to add test file: %v", err) - } - userCommit, err := worktree.Commit("User committed the file first", &git.CommitOptions{ - Author: &object.Signature{Name: "User", Email: "user@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit test file: %v", err) - } - - // Get count of commits on entire/checkpoints/v1 before the call - sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("entire/checkpoints/v1 branch not found: %v", err) - } - sessionsCommitBefore := sessionsRef.Hash() - - // Create metadata directory - sessionID := "2025-12-22-already-committed-test" - metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) - if err := os.MkdirAll(metadataDir, 0o750); err != nil { - t.Fatalf("failed to create metadata dir: %v", err) - } - logFile := filepath.Join(metadataDir, paths.TranscriptFileName) - if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { - t.Fatalf("failed to write log file: %v", err) - } - - metadataDirAbs, err := paths.AbsPath(metadataDir) - if err != nil { - metadataDirAbs = metadataDir - } - - // Call SaveStep with the file that was already committed - // This simulates the hook running after the user already committed the changes - ctx := StepContext{ - CommitMessage: "Should be skipped - file already committed", - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, - NewFiles: []string{"test.go"}, // File exists but already committed - ModifiedFiles: []string{}, - DeletedFiles: []string{}, - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - - // SaveStep should succeed without error (skip is not an error) - if err := s.SaveStep(ctx); err != nil { - t.Fatalf("SaveStep() error = %v", err) - } - - // Verify HEAD is still the user's commit (no new code commit created) - head, err := repo.Head() - if err != nil { - t.Fatalf("failed to get HEAD: %v", err) - } - if head.Hash() != userCommit { - t.Errorf("HEAD should still be user's commit %s, got %s", userCommit, head.Hash()) - } - - // Verify entire/checkpoints/v1 branch has no new commits (metadata not created) - sessionsRefAfter, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("entire/checkpoints/v1 branch not found after SaveStep: %v", err) - } - if sessionsRefAfter.Hash() != sessionsCommitBefore { - t.Errorf("entire/checkpoints/v1 should not have new commits when files already committed, before=%s after=%s", - sessionsCommitBefore, sessionsRefAfter.Hash()) - } -} - -// TestAutoCommitStrategy_SaveStep_NoChangesSkipped verifies that SaveStep -// skips creating metadata when there are no code changes to commit. -// This ensures 1:1 mapping between code commits and metadata commits. -func TestAutoCommitStrategy_SaveStep_NoChangesSkipped(t *testing.T) { - // Setup temp git repo - dir := t.TempDir() - repo, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - // Create initial commit - worktree, err := repo.Worktree() - if err != nil { - t.Fatalf("failed to get worktree: %v", err) - } - readmeFile := filepath.Join(dir, "README.md") - if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil { - t.Fatalf("failed to write README: %v", err) - } - if _, err := worktree.Add("README.md"); err != nil { - t.Fatalf("failed to add README: %v", err) - } - initialCommit, err := worktree.Commit("Initial commit", &git.CommitOptions{ - Author: &object.Signature{Name: "Test", Email: "test@test.com"}, - }) - if err != nil { - t.Fatalf("failed to commit: %v", err) - } - - t.Chdir(dir) - - // Setup strategy - s := NewAutoCommitStrategy() - if err := s.EnsureSetup(); err != nil { - t.Fatalf("EnsureSetup() error = %v", err) - } - - // Get count of commits on entire/checkpoints/v1 before the call - sessionsRef, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("entire/checkpoints/v1 branch not found: %v", err) - } - sessionsCommitBefore := sessionsRef.Hash() - - // Create metadata directory (without any file changes to commit) - sessionID := "2025-12-22-no-changes-test" - metadataDir := filepath.Join(dir, paths.EntireMetadataDir, sessionID) - if err := os.MkdirAll(metadataDir, 0o750); err != nil { - t.Fatalf("failed to create metadata dir: %v", err) - } - logFile := filepath.Join(metadataDir, paths.TranscriptFileName) - if err := os.WriteFile(logFile, []byte("test session log"), 0o644); err != nil { - t.Fatalf("failed to write log file: %v", err) - } - - metadataDirAbs, err := paths.AbsPath(metadataDir) - if err != nil { - metadataDirAbs = metadataDir - } - - // Call SaveStep with NO file changes (empty lists) - ctx := StepContext{ - CommitMessage: "Should be skipped", - MetadataDir: metadataDir, - MetadataDirAbs: metadataDirAbs, - NewFiles: []string{}, // Empty - no changes - ModifiedFiles: []string{}, // Empty - no changes - DeletedFiles: []string{}, // Empty - no changes - AuthorName: "Test", - AuthorEmail: "test@test.com", - } - - // SaveStep should succeed without error (skip is not an error) - if err := s.SaveStep(ctx); err != nil { - t.Fatalf("SaveStep() error = %v", err) - } - - // Verify HEAD is still the initial commit (no new code commit) - head, err := repo.Head() - if err != nil { - t.Fatalf("failed to get HEAD: %v", err) - } - if head.Hash() != initialCommit { - t.Errorf("HEAD should still be initial commit %s, got %s", initialCommit, head.Hash()) - } - - // Verify entire/checkpoints/v1 branch has no new commits (metadata not created) - sessionsRefAfter, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) - if err != nil { - t.Fatalf("entire/checkpoints/v1 branch not found after SaveStep: %v", err) - } - if sessionsRefAfter.Hash() != sessionsCommitBefore { - t.Errorf("entire/checkpoints/v1 should not have new commits when no code changes, before=%s after=%s", - sessionsCommitBefore, sessionsRefAfter.Hash()) - } -} diff --git a/cmd/entire/cli/strategy/clean_test.go b/cmd/entire/cli/strategy/clean_test.go index 72cb8680f..53b92b862 100644 --- a/cmd/entire/cli/strategy/clean_test.go +++ b/cmd/entire/cli/strategy/clean_test.go @@ -312,7 +312,7 @@ func TestDeleteShadowBranches_Empty(t *testing.T) { // its first checkpoint yet would be incorrectly marked as orphaned because it has: // - A session state file // - No checkpoints on entire/checkpoints/v1 -// - No shadow branch (if using auto-commit strategy, or before first checkpoint) +// - No shadow branch before first checkpoint // // This test should FAIL with the current implementation, demonstrating the bug. func TestListOrphanedSessionStates_RecentSessionNotOrphaned(t *testing.T) { diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index b0a0e651c..2634ee0b3 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -29,6 +29,8 @@ import ( const ( branchMain = "main" branchMaster = "master" + // Strategy name constants + StrategyNameManualCommit = "manual-commit" ) // errStop is a sentinel error used to break out of git log iteration. @@ -37,6 +39,30 @@ const ( // Each package needs its own package-scoped sentinel for git log iteration patterns. var errStop = errors.New("stop iteration") +// EnsureSetup ensures the strategy is properly set up. +func EnsureSetup() error { + if err := EnsureEntireGitignore(); err != nil { + return err + } + + // Ensure the entire/checkpoints/v1 orphan branch exists for permanent session storage + repo, err := OpenRepository() + if err != nil { + return fmt.Errorf("failed to open git repository: %w", err) + } + if err := EnsureMetadataBranch(repo); err != nil { + return fmt.Errorf("failed to ensure metadata branch: %w", err) + } + + // Install generic hooks (they delegate to strategy at runtime) + if !IsGitHookInstalled() { + if _, err := InstallGitHook(true); err != nil { + return fmt.Errorf("failed to install git hooks: %w", err) + } + } + return nil +} + // IsEmptyRepository returns true if the repository has no commits yet. // After git-init, HEAD points to an unborn branch (e.g., refs/heads/main) // whose target does not yet exist. repo.Head() returns ErrReferenceNotFound @@ -79,7 +105,6 @@ func IsAncestorOf(repo *git.Repository, commit, target plumbing.Hash) bool { // ListCheckpoints returns all checkpoints from the entire/checkpoints/v1 branch. // Scans sharded paths: // directories containing metadata.json. -// Used by both manual-commit and auto-commit strategies. func ListCheckpoints() ([]CheckpointInfo, error) { repo, err := OpenRepository() if err != nil { @@ -292,7 +317,7 @@ func EnsureMetadataBranch(repo *git.Repository) error { TreeHash: emptyTreeHash, Author: sig, Committer: sig, - Message: "Initialize metadata branch\n\nThis branch stores session metadata for the auto-commit strategy.\n", + Message: "Initialize metadata branch\n\nThis branch stores session metadata.\n", } // Note: No ParentHashes - this is an orphan commit @@ -708,73 +733,9 @@ func EnsureEntireGitignore() error { return nil } -// checkCanRewind checks if working directory is clean enough for rewind. -// Returns (canRewind, reason, error). Shared by shadow and linear-shadow strategies. -func checkCanRewind() (bool, string, error) { - repo, err := OpenRepository() - if err != nil { - return false, "", fmt.Errorf("failed to open git repository: %w", err) - } - - worktree, err := repo.Worktree() - if err != nil { - return false, "", fmt.Errorf("failed to get worktree: %w", err) - } - - status, err := worktree.Status() - if err != nil { - return false, "", fmt.Errorf("failed to get status: %w", err) - } - - if status.IsClean() { - return true, "", nil - } - - var modified, added, deleted []string - for file, st := range status { - // Skip .entire directory - if paths.IsInfrastructurePath(file) { - continue - } - - // Skip untracked files - if st.Worktree == git.Untracked { - continue - } - - switch { - case st.Staging == git.Added || st.Worktree == git.Added: - added = append(added, file) - case st.Staging == git.Deleted || st.Worktree == git.Deleted: - deleted = append(deleted, file) - case st.Staging == git.Modified || st.Worktree == git.Modified: - modified = append(modified, file) - } - } - - if len(modified) == 0 && len(added) == 0 && len(deleted) == 0 { - return true, "", nil - } - - var msg strings.Builder - msg.WriteString("You have uncommitted changes:\n") - for _, f := range modified { - msg.WriteString(fmt.Sprintf(" modified: %s\n", f)) - } - for _, f := range added { - msg.WriteString(fmt.Sprintf(" added: %s\n", f)) - } - for _, f := range deleted { - msg.WriteString(fmt.Sprintf(" deleted: %s\n", f)) - } - msg.WriteString("\nPlease commit or stash your changes before rewinding.") - - return false, msg.String(), nil -} - // checkCanRewindWithWarning checks working directory and returns a warning with diff stats. -// Unlike checkCanRewind, this always returns canRewind=true but includes a warning message -// with +/- line stats for uncommitted changes. Used by manual-commit strategy. +// Always returns canRewind=true but includes a warning message with +/- line stats for +// uncommitted changes. Used by manual-commit strategy. func checkCanRewindWithWarning() (bool, string, error) { repo, err := OpenRepository() if err != nil { @@ -1315,7 +1276,7 @@ func createCommit(repo *git.Repository, treeHash, parentHash plumbing.Hash, mess // // If metadataDir is provided, looks for files at metadataDir/prompt.txt or metadataDir/context.md. // If metadataDir is empty, first tries the root of the tree (for when the tree is already -// the session directory, e.g., auto-commit strategy's sharded metadata), then falls back to +// the session directory), then falls back to // searching for .entire/metadata/*/prompt.txt or context.md (for full worktree trees). func getSessionDescriptionFromTree(tree *object.Tree, metadataDir string) string { // Helper to read first line from a file in tree diff --git a/cmd/entire/cli/strategy/push_common.go b/cmd/entire/cli/strategy/push_common.go index 48b531665..95ce958a0 100644 --- a/cmd/entire/cli/strategy/push_common.go +++ b/cmd/entire/cli/strategy/push_common.go @@ -18,7 +18,6 @@ import ( ) // pushSessionsBranchCommon is the shared implementation for pushing session branches. -// Used by both manual-commit and auto-commit strategies. // By default, session logs are pushed automatically alongside user pushes. // Configuration (stored in .entire/settings.json under strategy_options.push_sessions): // - false: disable automatic pushing diff --git a/cmd/entire/cli/strategy/registry.go b/cmd/entire/cli/strategy/registry.go index dbc69b0bd..3ca8ada6a 100644 --- a/cmd/entire/cli/strategy/registry.go +++ b/cmd/entire/cli/strategy/registry.go @@ -50,27 +50,3 @@ func List() []string { sort.Strings(names) return names } - -// Strategy name constants -const ( - StrategyNameManualCommit = "manual-commit" - StrategyNameAutoCommit = "auto-commit" -) - -// DefaultStrategyName is the name of the default strategy. -// Manual-commit is the recommended strategy for most workflows. -const DefaultStrategyName = StrategyNameManualCommit - -// Default returns the default strategy. -// Falls back to returning nil if no strategies are registered. -func Default() Strategy { - s, err := Get(DefaultStrategyName) - if err != nil { - // Fallback: return the first registered strategy - names := List() - if len(names) > 0 { - s, _ = Get(names[0]) //nolint:errcheck // Fallback to first strategy, error already handled above - } - } - return s -} diff --git a/cmd/entire/cli/strategy/rewind_test.go b/cmd/entire/cli/strategy/rewind_test.go index bdf560c2b..d76b9ba2c 100644 --- a/cmd/entire/cli/strategy/rewind_test.go +++ b/cmd/entire/cli/strategy/rewind_test.go @@ -248,39 +248,6 @@ func TestShadowStrategy_PreviewRewind_LogsOnly(t *testing.T) { } } -func TestDualStrategy_PreviewRewind(t *testing.T) { - dir := t.TempDir() - _, err := git.PlainInit(dir, false) - if err != nil { - t.Fatalf("failed to init git repo: %v", err) - } - - t.Chdir(dir) - - s := &AutoCommitStrategy{} - - // Dual strategy uses git reset which doesn't delete untracked files - point := RewindPoint{ - ID: "abc123", - Message: "Checkpoint", - Date: time.Now(), - } - - preview, err := s.PreviewRewind(point) - if err != nil { - t.Fatalf("PreviewRewind() error = %v", err) - } - - if preview == nil { - t.Fatal("PreviewRewind() returned nil preview") - } - - // Should be empty since git reset doesn't delete untracked files - if len(preview.FilesToDelete) > 0 { - t.Errorf("Dual strategy preview should have no files to delete, got: %v", preview.FilesToDelete) - } -} - func TestResolveAgentForRewind(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 48e83f9ee..e9c8dcb98 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -349,17 +349,15 @@ type Strategy interface { // PreviewRewind returns what will happen if rewinding to the given point. // This allows showing warnings about files that will be deleted before the rewind. - // Returns nil if preview is not supported (e.g., auto-commit strategy). + // Returns nil if preview is not supported PreviewRewind(point RewindPoint) (*RewindPreview, error) // GetTaskCheckpoint returns the task checkpoint for a given rewind point. - // For strategies that store checkpoints in git (auto-commit), this reads from the branch. // For strategies that store checkpoints on disk (commit, manual-commit), this reads from the filesystem. // Returns nil, nil if not a task checkpoint or checkpoint not found. GetTaskCheckpoint(point RewindPoint) (*TaskCheckpoint, error) // GetTaskCheckpointTranscript returns the session transcript for a task checkpoint. - // For strategies that store transcripts in git (auto-commit), this reads from the branch. // For strategies that store transcripts on disk (commit, manual-commit), this reads from the filesystem. GetTaskCheckpointTranscript(point RewindPoint) ([]byte, error) @@ -368,11 +366,6 @@ type Strategy interface { // Returns ErrNoSession if no session info is available. GetSessionInfo() (*SessionInfo, error) - // EnsureSetup ensures the strategy's required setup is in place, - // installing any missing pieces (git hooks, gitignore entries, etc.). - // Returns nil if setup is complete or was successfully installed. - EnsureSetup() error - // NOTE: ListSessions and GetSession are standalone functions in session.go. // They read from entire/checkpoints/v1 and merge with SessionSource if implemented. @@ -391,7 +384,7 @@ type Strategy interface { GetSessionContext(sessionID string) string // GetCheckpointLog returns the session transcript for a specific checkpoint. - // For strategies that store transcripts in git branches (auto-commit, manual-commit), + // For strategies that store transcripts in git branches (manual-commit), // this reads from the checkpoint's commit tree. // For strategies that store on disk (commit), reads from the filesystem. // Returns ErrNoMetadata if transcript is not available. diff --git a/docs/architecture/claude-hooks-integration.md b/docs/architecture/claude-hooks-integration.md index 69e7d5602..2074f69c5 100644 --- a/docs/architecture/claude-hooks-integration.md +++ b/docs/architecture/claude-hooks-integration.md @@ -117,10 +117,9 @@ Fires when Claude finishes responding. Does **not** fire on user interrupt (Ctrl - Builds a `SaveContext` with session ID, file lists, metadata paths, git author info, and token usage. - Calls `strategy.SaveChanges(ctx)` to create the checkpoint. - **Manual-commit**: Builds a git tree in-memory and commits to the shadow branch. - - **Auto-commit**: Creates a commit on the active branch with the `Entire-Checkpoint` trailer. - Token usage is stored in `metadata.json` for later analysis and reporting. -7. **Update Session State**: Updates `CheckpointTranscriptStart` to track transcript position for detecting new content in future checkpoints (auto-commit strategy only). +7. **Update Session State**: Updates `CheckpointTranscriptStart` to track transcript position for detecting new content in future checkpoints. 8. **Cleanup**: Deletes the temporary `.entire/tmp/pre-prompt-.json` file. diff --git a/docs/architecture/logging.md b/docs/architecture/logging.md index 030932412..504607c29 100644 --- a/docs/architecture/logging.md +++ b/docs/architecture/logging.md @@ -149,7 +149,6 @@ Logs are tagged with a `component` field indicating the logging source: | `cmd/entire/cli/hook_registry.go` | Hook wrapper logging | | `cmd/entire/cli/strategy/manual_commit_git.go` | Manual-commit checkpoint logging | | `cmd/entire/cli/strategy/manual_commit_hooks.go` | Condensation and branch cleanup logging | -| `cmd/entire/cli/strategy/auto_commit.go` | Auto-commit checkpoint logging | ### Log Entry Structure diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index 7125a09df..c260d6d67 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -145,13 +145,6 @@ func (s *ManualCommitStrategy) CondenseSession( ) (*CondenseResult, error) ``` -**Auto-commit** writes committed checkpoints directly: - -```go -// SaveChanges creates a commit on the active branch and writes metadata. -func (s *AutoCommitStrategy) SaveChanges(ctx SaveContext) error -``` - ## Storage | Type | Location | Contents | From a73cb473416a74f9ed7cd01c8da08e1370906971 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Thu, 19 Feb 2026 11:33:39 +1100 Subject: [PATCH 02/11] fix lint Entire-Checkpoint: 3786a4d9fefd --- cmd/entire/cli/config_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/config_test.go b/cmd/entire/cli/config_test.go index 7debf43bc..f729ec343 100644 --- a/cmd/entire/cli/config_test.go +++ b/cmd/entire/cli/config_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "strings" "testing" - ) const ( @@ -128,7 +127,7 @@ func TestIsEnabled(t *testing.T) { } // Test 3: Settings with enabled: true - should return true - settingsContent = `{"enabled": true}` + settingsContent = testSettingsEnabled if err := os.WriteFile(EntireSettingsFile, []byte(settingsContent), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -162,7 +161,7 @@ func TestLoadEntireSettings_LocalOverridesStrategy(t *testing.T) { t.Fatalf("Failed to write settings file: %v", err) } - localSettings := `{"enabled": true}` + localSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -253,7 +252,7 @@ func TestLoadEntireSettings_OnlyLocalFileExists(t *testing.T) { setupLocalOverrideTestDir(t) // No base settings file - localSettings := `{"enabled": true}` + localSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } From 6231b83593c49ac824917c3274afd9464018849c Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Thu, 19 Feb 2026 12:21:12 +1100 Subject: [PATCH 03/11] fix test Entire-Checkpoint: 68f26bfdf530 --- .../cli/integration_test/resume_test.go | 40 +++++- .../cli/integration_test/setup_cmd_test.go | 24 ++-- cmd/entire/cli/integration_test/testenv.go | 2 +- .../cli/integration_test/worktree_test.go | 126 ------------------ cmd/entire/cli/paths/paths.go | 2 +- 5 files changed, 52 insertions(+), 142 deletions(-) diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index b7b30a950..6459a02e1 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -43,6 +43,9 @@ func TestResume_SwitchBranchWithSession(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create a hello script", "hello.rb") + // Remember the feature branch name featureBranch := env.GetCurrentBranch() @@ -105,6 +108,9 @@ func TestResume_AlreadyOnBranch(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create a test script", "test.js") + currentBranch := env.GetCurrentBranch() // Run resume on the branch we're already on @@ -232,6 +238,9 @@ func TestResume_SessionLogAlreadyExists(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Pre-create a session log in Claude project dir with different content @@ -313,6 +322,9 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { t.Fatalf("SimulateStop session2 failed: %v", err) } + // Commit the sessions' changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Update to version 2", "file.txt") + featureBranch := env.GetCurrentBranch() // Switch to main @@ -324,8 +336,8 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { t.Fatalf("resume failed: %v\nOutput: %s", err, output) } - // Should show session info (from the most recent session) - if !strings.Contains(output, "Session:") { + // Should show session info (multi-session output says "Restored N sessions") + if !strings.Contains(output, "Restored 2 sessions") && !strings.Contains(output, "Session:") { t.Errorf("output should contain session info, got: %s", output) } @@ -358,6 +370,9 @@ func TestResume_CheckpointWithoutMetadata(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create real file", "real.txt") + // Create a new branch for the orphan checkpoint test env.GitCheckoutNewBranch("feature/orphan-checkpoint") @@ -416,6 +431,9 @@ func TestResume_AfterMergingMain(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create a hello script", "hello.rb") + // Remember the feature branch name featureBranch := env.GetCurrentBranch() @@ -592,6 +610,9 @@ func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -650,6 +671,9 @@ func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -709,6 +733,9 @@ func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -773,6 +800,9 @@ func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -838,6 +868,9 @@ func TestResume_CheckpointNewerTimestamp(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with an OLDER timestamp than the checkpoint @@ -1004,6 +1037,9 @@ func TestResume_LocalLogNoTimestamp(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log WITHOUT a valid timestamp (can't be parsed) diff --git a/cmd/entire/cli/integration_test/setup_cmd_test.go b/cmd/entire/cli/integration_test/setup_cmd_test.go index f964574c8..ef612b1c4 100644 --- a/cmd/entire/cli/integration_test/setup_cmd_test.go +++ b/cmd/entire/cli/integration_test/setup_cmd_test.go @@ -72,8 +72,8 @@ func TestEnableDisable(t *testing.T) { t.Errorf("Expected status to show 'Enabled', got: %s", stdout) } - // Disable - stdout = env.RunCLI("disable") + // Disable (using --project so re-enable can override cleanly) + stdout = env.RunCLI("disable", "--project") if !strings.Contains(stdout, "disabled") { t.Errorf("Expected disable output to contain 'disabled', got: %s", stdout) } @@ -84,8 +84,8 @@ func TestEnableDisable(t *testing.T) { t.Errorf("Expected status to show 'Disabled', got: %s", stdout) } - // Re-enable (using --strategy flag for non-interactive mode) - stdout = env.RunCLI("enable", "--strategy", strategyName) + // Re-enable (using --agent for non-interactive mode) + stdout = env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") if !strings.Contains(stdout, "Ready.") { t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) } @@ -161,8 +161,8 @@ func TestEnableWhenDisabled(t *testing.T) { // Disable Entire env.SetEnabled(false) - // Enable command should work (using --strategy flag for non-interactive mode) - stdout := env.RunCLI("enable", "--strategy", strategyName) + // Enable command should work (using --agent for non-interactive mode) + stdout := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") if !strings.Contains(stdout, "Ready.") { t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) } @@ -192,7 +192,7 @@ func TestEnableDefaultStrategy(t *testing.T) { t.Errorf("Expected output to contain 'Ready.', got: %s", stdout) } - // Verify settings file has manual-commit strategy + // Verify settings file exists and has enabled field settingsPath := filepath.Join(env.RepoDir, ".entire", paths.SettingsFileName) data, err := os.ReadFile(settingsPath) if err != nil { @@ -204,16 +204,16 @@ func TestEnableDefaultStrategy(t *testing.T) { t.Fatalf("Failed to parse settings: %v", err) } - strategy, ok := settings["strategy"].(string) + enabled, ok := settings["enabled"].(bool) if !ok { - t.Fatalf("Strategy not found in settings: %v", settings) + t.Fatalf("Enabled not found in settings: %v", settings) } - if strategy != "manual-commit" { - t.Errorf("Expected default strategy to be 'manual-commit', got: %s", strategy) + if !enabled { + t.Error("Expected enabled to be true") } - // Also verify via status command + // Verify status shows manual-commit (the only strategy) stdout = env.RunCLI("status") if !strings.Contains(stdout, "manual-commit") { t.Errorf("Expected status to show 'manual-commit', got: %s", stdout) diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index faa8ca8fc..532507b5c 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -331,7 +331,7 @@ func (env *TestEnv) initEntireInternal(strategyName string, strategyOptions map[ // the agent from installed hooks (detect presence) or checkpoint metadata. // The settings parser uses DisallowUnknownFields(), so only recognized fields are allowed. settings := map[string]any{ - "strategy": strategyName, + "enabled": true, "local_dev": true, // Note: git-triggered hooks won't work (path is relative); tests call hooks via getTestBinary() instead } if strategyOptions != nil { diff --git a/cmd/entire/cli/integration_test/worktree_test.go b/cmd/entire/cli/integration_test/worktree_test.go index 2a3f62145..c44bd4f39 100644 --- a/cmd/entire/cli/integration_test/worktree_test.go +++ b/cmd/entire/cli/integration_test/worktree_test.go @@ -6,138 +6,12 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" - "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/go-git/go-git/v5/plumbing" ) -// TestWorktreeCommitPersistence verifies that commits made via go-git -// in a linked worktree are actually persisted and visible to git CLI. -// -// This is a regression test for the EnableDotGitCommonDir fix. -// Without that fix, go-git commits silently fail in worktrees. -// -// NOTE: This test uses os.Chdir() so it cannot use t.Parallel(). -func TestWorktreeCommitPersistence(t *testing.T) { - // Test worktree commit persistence with manual-commit strategy - worktreeStrategies := []string{ - strategy.StrategyNameManualCommit, - } - - RunForStrategiesSequential(t, worktreeStrategies, func(t *testing.T, strat string) { - env := NewTestEnv(t) - env.InitRepo() - env.InitEntire(strat) - - env.WriteFile("README.md", "# Main Repo") - env.GitAdd("README.md") - env.GitCommit("Initial commit") - - // Create a worktree - worktreeDir := filepath.Join(t.TempDir(), "worktree") - if resolved, err := filepath.EvalSymlinks(filepath.Dir(worktreeDir)); err == nil { - worktreeDir = filepath.Join(resolved, "worktree") - } - - cmd := exec.Command("git", "worktree", "add", worktreeDir, "-b", "worktree-branch") - cmd.Dir = env.RepoDir - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to create worktree: %v\nOutput: %s", err, output) - } - - // Initialize .entire in worktree - worktreeEntireDir := filepath.Join(worktreeDir, ".entire") - if err := os.MkdirAll(worktreeEntireDir, 0o755); err != nil { - t.Fatalf("failed to create .entire in worktree: %v", err) - } - settingsSrc := filepath.Join(env.RepoDir, ".entire", paths.SettingsFileName) - settingsDst := filepath.Join(worktreeEntireDir, paths.SettingsFileName) - settingsData, err := os.ReadFile(settingsSrc) - if err != nil { - t.Fatalf("failed to read settings: %v", err) - } - if err := os.WriteFile(settingsDst, settingsData, 0o644); err != nil { - t.Fatalf("failed to write settings to worktree: %v", err) - } - if err := os.MkdirAll(filepath.Join(worktreeEntireDir, "tmp"), 0o755); err != nil { - t.Fatalf("failed to create tmp dir: %v", err) - } - - // Change to worktree directory - originalWd, _ := os.Getwd() - if err := os.Chdir(worktreeDir); err != nil { - t.Fatalf("failed to chdir to worktree: %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(originalWd) - }) - - // Create a file in the worktree - testFile := filepath.Join(worktreeDir, "worktree-file.txt") - if err := os.WriteFile(testFile, []byte("worktree content"), 0o644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - // Create a HookRunner pointing to the worktree - runner := NewHookRunner(worktreeDir, env.ClaudeProjectDir, t) - - // Simulate a session that creates a commit - sessionID := "worktree-test-session" - transcriptPath := filepath.Join(worktreeEntireDir, "tmp", sessionID+".jsonl") - - builder := NewTranscriptBuilder() - builder.AddUserMessage("Add worktree file") - builder.AddAssistantMessage("I'll add the file.") - toolID := builder.AddToolUse("mcp__acp__Write", "worktree-file.txt", "worktree content") - builder.AddToolResult(toolID) - builder.AddAssistantMessage("Done!") - if err := builder.WriteToFile(transcriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } - - if err := runner.SimulateUserPromptSubmit(sessionID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - if err := runner.SimulateStop(sessionID, transcriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - // CRITICAL: Verify commit persisted using git CLI (not go-git) - gitLogCmd := exec.Command("git", "log", "--oneline", "-5") - gitLogCmd.Dir = worktreeDir - logOutput, err := gitLogCmd.CombinedOutput() - if err != nil { - t.Fatalf("git log failed: %v\nOutput: %s", err, logOutput) - } - - logLines := strings.Split(strings.TrimSpace(string(logOutput)), "\n") - if len(logLines) < 2 { - t.Errorf("expected at least 2 commits (initial + session), got %d:\n%s", - len(logLines), logOutput) - } - - // Verify git status shows clean working tree - gitStatusCmd := exec.Command("git", "status", "--porcelain") - gitStatusCmd.Dir = worktreeDir - statusOutput, err := gitStatusCmd.CombinedOutput() - if err != nil { - t.Fatalf("git status failed: %v\nOutput: %s", err, statusOutput) - } - - if strings.Contains(string(statusOutput), "worktree-file.txt") { - t.Errorf("worktree-file.txt still appears in git status (commit didn't persist):\n%s", - statusOutput) - } - - t.Logf("Worktree commit test passed for strategy %s", strat) - t.Logf("Git log:\n%s", logOutput) - }) -} - // TestWorktreeOpenRepository verifies that OpenRepository() works correctly // in a worktree context by checking it can read HEAD and refs. // diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 6637d3fe3..5625c431d 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -33,7 +33,7 @@ const ( SettingsFileName = "settings.json" ) -// MetadataBranchName is the orphan branch used by manual-commit strategie to store metadata +// MetadataBranchName is the orphan branch used by manual-commit strategy to store metadata const MetadataBranchName = "entire/checkpoints/v1" // CheckpointPath returns the sharded storage path for a checkpoint ID. From 052a0fdc12c99961d99a19bf4fb4a278fc2368dd Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Thu, 19 Feb 2026 13:22:55 +1100 Subject: [PATCH 04/11] status and doctor display warnings if strategy is found Entire-Checkpoint: d4a45cb7c16a --- cmd/entire/cli/doctor.go | 4 ++ cmd/entire/cli/doctor_test.go | 33 ++++++++++++ cmd/entire/cli/settings/settings.go | 34 ++++++++++++ cmd/entire/cli/settings/settings_test.go | 69 ++++++++++++++++++++++++ cmd/entire/cli/status.go | 5 +- cmd/entire/cli/status_test.go | 48 +++++++++++++++++ 6 files changed, 192 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index 1b5a0a940..e82c7063f 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/huh" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/go-git/go-git/v5" @@ -59,6 +60,9 @@ type stuckSession struct { } func runSessionsFix(cmd *cobra.Command, force bool) error { + w := cmd.OutOrStdout() + defer func() { settings.WriteDeprecatedStrategyWarnings(w) }() + // Load all session states states, err := strategy.ListSessionStates() if err != nil { diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index 152c6dc76..b1383208e 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -1,6 +1,8 @@ package cli import ( + "bytes" + "strings" "testing" "time" @@ -11,6 +13,7 @@ import ( "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -286,3 +289,33 @@ func TestClassifySession_WorktreeIDInShadowBranch(t *testing.T) { expectedBranch := checkpoint.ShadowBranchNameForCommit(baseCommit, worktreeID) assert.Equal(t, expectedBranch, result.ShadowBranch) } + +func TestRunSessionsFix_DeprecatedStrategyWarning(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + writeSettings(t, `{"enabled": true, "strategy": "auto-commit"}`) + + var stdout bytes.Buffer + cmd := &cobra.Command{Use: "doctor"} + cmd.SetOut(&stdout) + + // runSessionsFix should show warning after "No stuck sessions found." + err := runSessionsFix(cmd, false) + require.NoError(t, err) + + output := stdout.String() + if !strings.Contains(output, "no longer needed") { + t.Errorf("Expected deprecation warning, got: %s", output) + } + if !strings.Contains(output, "strategy") { + t.Errorf("Expected warning to mention 'strategy', got: %s", output) + } + + // Warning should appear after the main output + noStuckIdx := strings.Index(output, "No stuck sessions found.") + warningIdx := strings.Index(output, "no longer needed") + if noStuckIdx >= warningIdx { + t.Errorf("Expected warning after main output, got: %s", output) + } +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 03d3e4870..1cfd40f79 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" @@ -43,6 +44,10 @@ type EntireSettings struct { // Telemetry controls anonymous usage analytics. // nil = not asked yet (show prompt), true = opted in, false = opted out Telemetry *bool `json:"telemetry,omitempty"` + + // Deprecated: no longer used. Exists to tolerate old settings files + // that still contain "strategy": "auto-commit" or similar. + Strategy string `json:"strategy,omitempty"` } // Load loads the Entire settings from .entire/settings.json, @@ -254,6 +259,35 @@ func (s *EntireSettings) IsPushSessionsDisabled() bool { return false } +// FilesWithDeprecatedStrategy returns the relative paths of settings files +// that still contain the deprecated "strategy" field. +func FilesWithDeprecatedStrategy() []string { + var files []string + for _, rel := range []string{EntireSettingsFile, EntireSettingsLocalFile} { + abs, err := paths.AbsPath(rel) + if err != nil { + abs = rel // Fallback to relative + } + s, err := LoadFromFile(abs) + if err != nil || s.Strategy == "" { + continue + } + files = append(files, rel) + } + return files +} + +// WriteDeprecatedStrategyWarnings writes user-friendly deprecation warnings +// for each settings file that still contains the "strategy" field. +// Returns true if any warnings were written. +func WriteDeprecatedStrategyWarnings(w io.Writer) bool { + files := FilesWithDeprecatedStrategy() + for _, f := range files { + fmt.Fprintf(w, "Note: \"%s\" in %s is no longer needed and can be removed.\n", "strategy", f) + } + return len(files) > 0 +} + // Save saves the settings to .entire/settings.json. func Save(settings *EntireSettings) error { return saveToFile(settings, EntireSettingsFile) diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index 1a037fa28..9d4cf891a 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -131,6 +131,75 @@ func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) { } } +func TestLoad_AcceptsDeprecatedStrategyField(t *testing.T) { + tmpDir := t.TempDir() + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } + + settingsFile := filepath.Join(entireDir, "settings.json") + if err := os.WriteFile(settingsFile, []byte(`{"enabled": true, "strategy": "auto-commit"}`), 0o644); err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + + t.Chdir(tmpDir) + + s, err := Load() + if err != nil { + t.Fatalf("expected no error for deprecated strategy field, got: %v", err) + } + if s.Strategy != "auto-commit" { + t.Errorf("expected strategy 'auto-commit', got %q", s.Strategy) + } +} + +func TestFilesWithDeprecatedStrategy(t *testing.T) { + tmpDir := t.TempDir() + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + + t.Chdir(tmpDir) + + // No strategy field → empty result + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled": true}`), 0o644); err != nil { + t.Fatal(err) + } + if files := FilesWithDeprecatedStrategy(); len(files) != 0 { + t.Errorf("expected no deprecated files, got %v", files) + } + + // Add strategy to project settings + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled": true, "strategy": "auto-commit"}`), 0o644); err != nil { + t.Fatal(err) + } + files := FilesWithDeprecatedStrategy() + if len(files) != 1 || files[0] != EntireSettingsFile { + t.Errorf("expected [%s], got %v", EntireSettingsFile, files) + } + + // Also add strategy to local settings + if err := os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(`{"strategy": "manual-commit"}`), 0o644); err != nil { + t.Fatal(err) + } + files = FilesWithDeprecatedStrategy() + if len(files) != 2 { + t.Errorf("expected 2 deprecated files, got %v", files) + } +} + // containsUnknownField checks if the error message indicates an unknown field func containsUnknownField(msg string) bool { // Go's json package reports unknown fields with this message format diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index 42a06be3e..b98564fe9 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -85,9 +85,10 @@ func runStatus(w io.Writer, detailed bool) error { return fmt.Errorf("failed to load settings: %w", err) } + fmt.Fprintln(w) + settings.WriteDeprecatedStrategyWarnings(w) fmt.Fprintln(w) fmt.Fprintln(w, formatSettingsStatusShort(s, sty)) - if s.Enabled { writeActiveSessions(w, sty) } @@ -103,6 +104,8 @@ func runStatusDetailed(w io.Writer, sty statusStyles, settingsPath, localSetting return fmt.Errorf("failed to load settings: %w", err) } fmt.Fprintln(w) + settings.WriteDeprecatedStrategyWarnings(w) + fmt.Fprintln(w) fmt.Fprintln(w, formatSettingsStatusShort(effectiveSettings, sty)) fmt.Fprintln(w) // blank line diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 4c14ca3c0..127c86a0b 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -340,6 +340,54 @@ func TestRunStatus_ShowsManualCommitStrategy(t *testing.T) { } } +func TestRunStatus_DeprecatedStrategyWarning(t *testing.T) { + setupTestRepo(t) + writeSettings(t, `{"enabled": true, "strategy": "auto-commit"}`) + + var stdout bytes.Buffer + if err := runStatus(&stdout, false); err != nil { + t.Fatalf("runStatus() error = %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "no longer needed") { + t.Errorf("Expected deprecation warning, got: %s", output) + } + if !strings.Contains(output, "strategy") { + t.Errorf("Expected warning to mention 'strategy', got: %s", output) + } +} + +func TestRunStatus_DeprecatedStrategyWarning_Detailed(t *testing.T) { + setupTestRepo(t) + writeSettings(t, `{"enabled": true, "strategy": "auto-commit"}`) + + var stdout bytes.Buffer + if err := runStatus(&stdout, true); err != nil { + t.Fatalf("runStatus() error = %v", err) + } + + output := stdout.String() + if !strings.Contains(output, "no longer needed") { + t.Errorf("Expected deprecation warning in detailed mode, got: %s", output) + } +} + +func TestRunStatus_NoWarningWithoutStrategy(t *testing.T) { + setupTestRepo(t) + writeSettings(t, testSettingsEnabled) + + var stdout bytes.Buffer + if err := runStatus(&stdout, false); err != nil { + t.Fatalf("runStatus() error = %v", err) + } + + output := stdout.String() + if strings.Contains(output, "no longer needed") { + t.Errorf("Expected no deprecation warning, got: %s", output) + } +} + func TestTimeAgo(t *testing.T) { tests := []struct { name string From aad31b3254e3af9fe9f4d96621bf71b56ded48a0 Mon Sep 17 00:00:00 2001 From: gtrrz-victor Date: Mon, 23 Feb 2026 16:58:24 +1100 Subject: [PATCH 05/11] Update cmd/entire/cli/settings/settings.go Co-authored-by: paul <423357+toothbrush@users.noreply.github.com> --- cmd/entire/cli/settings/settings.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 1cfd40f79..c3dd18fea 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -283,7 +283,7 @@ func FilesWithDeprecatedStrategy() []string { func WriteDeprecatedStrategyWarnings(w io.Writer) bool { files := FilesWithDeprecatedStrategy() for _, f := range files { - fmt.Fprintf(w, "Note: \"%s\" in %s is no longer needed and can be removed.\n", "strategy", f) + fmt.Fprintf(w, "Note: \"%s\" in %s is no longer needed and can be removed. 'manual-commit' is now the only supported strategy.\n", "strategy", f) } return len(files) > 0 } From 3bdf35b3c6a649b26f173dafc6e3530e90144056 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Wed, 25 Feb 2026 12:35:17 +1100 Subject: [PATCH 06/11] fix lint/test after resolve conflicts --- cmd/entire/cli/bench_enable_test.go | 6 ++--- .../e2e_test/resume_relocated_repo_test.go | 2 +- cmd/entire/cli/settings/settings.go | 2 +- cmd/entire/cli/status_test.go | 16 ++++++------- cmd/entire/cli/strategy/common.go | 20 ++++++++-------- cmd/entire/cli/strategy/manual_commit.go | 24 ------------------- 6 files changed, 23 insertions(+), 47 deletions(-) diff --git a/cmd/entire/cli/bench_enable_test.go b/cmd/entire/cli/bench_enable_test.go index 90255b811..7dde002e5 100644 --- a/cmd/entire/cli/bench_enable_test.go +++ b/cmd/entire/cli/bench_enable_test.go @@ -34,7 +34,7 @@ func BenchmarkEnableCommand(b *testing.B) { b.StartTimer() w := &bytes.Buffer{} - if err := setupAgentHooksNonInteractive(w, ag, "", true, false, false, false); err != nil { + if err := setupAgentHooksNonInteractive(w, ag, true, false, false, false); err != nil { b.Fatalf("setupAgentHooksNonInteractive: %v", err) } } @@ -49,7 +49,7 @@ func BenchmarkEnableCommand(b *testing.B) { // First enable to set up everything w := &bytes.Buffer{} - if err := setupAgentHooksNonInteractive(w, ag, "", true, false, false, false); err != nil { + if err := setupAgentHooksNonInteractive(w, ag, true, false, false, false); err != nil { b.Fatalf("initial enable: %v", err) } b.StartTimer() @@ -61,7 +61,7 @@ func BenchmarkEnableCommand(b *testing.B) { b.StartTimer() w.Reset() - if err := setupAgentHooksNonInteractive(w, ag, "", true, false, false, false); err != nil { + if err := setupAgentHooksNonInteractive(w, ag, true, false, false, false); err != nil { b.Fatalf("setupAgentHooksNonInteractive: %v", err) } } diff --git a/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go b/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go index c012fa302..4768d62a8 100644 --- a/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go +++ b/cmd/entire/cli/e2e_test/resume_relocated_repo_test.go @@ -33,7 +33,7 @@ func TestE2E_ResumeInRelocatedRepo(t *testing.T) { t.Parallel() // Create an initial test environment at the original location - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) originalDir := env.RepoDir t.Logf("Original repo location: %s", originalDir) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index c3dd18fea..1e4b54011 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -283,7 +283,7 @@ func FilesWithDeprecatedStrategy() []string { func WriteDeprecatedStrategyWarnings(w io.Writer) bool { files := FilesWithDeprecatedStrategy() for _, f := range files { - fmt.Fprintf(w, "Note: \"%s\" in %s is no longer needed and can be removed. 'manual-commit' is now the only supported strategy.\n", "strategy", f) + fmt.Fprintf(w, "Note: \"%s\" in %s is no longer needed and can be removed. 'manual-commit' is now the only supported strategy.\n", "strategy", f) } return len(files) > 0 } diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 127c86a0b..f9b709f68 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -288,8 +288,8 @@ func TestRunStatus_BothProjectAndLocal(t *testing.T) { output := stdout.String() // Should show effective status first (local overrides project) - if !strings.Contains(output, "Disabled") || !strings.Contains(output, "auto-commit") { - t.Errorf("Expected output to show effective 'Disabled' with 'auto-commit', got: %s", output) + if !strings.Contains(output, "Disabled") || !strings.Contains(output, "manual-commit") { + t.Errorf("Expected output to show effective 'Disabled' with 'manual-commit', got: %s", output) } // Should show both settings separately if !strings.Contains(output, "Project") || !strings.Contains(output, "manual-commit") { @@ -315,8 +315,8 @@ func TestRunStatus_BothProjectAndLocal_Short(t *testing.T) { output := stdout.String() // Should show merged/effective state (local overrides project) - if !strings.Contains(output, "Disabled") || !strings.Contains(output, "auto-commit") { - t.Errorf("Expected output to show 'Disabled' with 'auto-commit', got: %s", output) + if !strings.Contains(output, "Disabled") || !strings.Contains(output, "manual-commit") { + t.Errorf("Expected output to show 'Disabled' with 'manual-commit', got: %s", output) } } @@ -1033,7 +1033,7 @@ func TestFormatSettingsStatusShort_Disabled(t *testing.T) { sty := statusStyles{colorEnabled: false, width: 60} s := &EntireSettings{ Enabled: false, - Strategy: "auto-commit", + Strategy: "manual-commit", } result := formatSettingsStatusShort(s, sty) @@ -1044,7 +1044,7 @@ func TestFormatSettingsStatusShort_Disabled(t *testing.T) { if !strings.Contains(result, "Disabled") { t.Errorf("Expected 'Disabled' in output, got: %q", result) } - if !strings.Contains(result, "auto-commit") { + if !strings.Contains(result, "manual-commit") { t.Errorf("Expected strategy in output, got: %q", result) } } @@ -1077,7 +1077,7 @@ func TestFormatSettingsStatus_LocalDisabled(t *testing.T) { sty := statusStyles{colorEnabled: false, width: 60} s := &EntireSettings{ Enabled: false, - Strategy: "auto-commit", + Strategy: "manual-commit", } result := formatSettingsStatus("Local", s, sty) @@ -1088,7 +1088,7 @@ func TestFormatSettingsStatus_LocalDisabled(t *testing.T) { if !strings.Contains(result, "disabled") { t.Errorf("Expected 'disabled' in output, got: %q", result) } - if !strings.Contains(result, "auto-commit") { + if !strings.Contains(result, "manual-commit") { t.Errorf("Expected strategy in output, got: %q", result) } } diff --git a/cmd/entire/cli/strategy/common.go b/cmd/entire/cli/strategy/common.go index 2634ee0b3..541a00c49 100644 --- a/cmd/entire/cli/strategy/common.go +++ b/cmd/entire/cli/strategy/common.go @@ -39,6 +39,15 @@ const ( // Each package needs its own package-scoped sentinel for git log iteration patterns. var errStop = errors.New("stop iteration") +// IsEmptyRepository returns true if the repository has no commits yet. +// After git-init, HEAD points to an unborn branch (e.g., refs/heads/main) +// whose target does not yet exist. repo.Head() returns ErrReferenceNotFound +// in this case. +func IsEmptyRepository(repo *git.Repository) bool { + _, err := repo.Head() + return errors.Is(err, plumbing.ErrReferenceNotFound) +} + // EnsureSetup ensures the strategy is properly set up. func EnsureSetup() error { if err := EnsureEntireGitignore(); err != nil { @@ -56,22 +65,13 @@ func EnsureSetup() error { // Install generic hooks (they delegate to strategy at runtime) if !IsGitHookInstalled() { - if _, err := InstallGitHook(true); err != nil { + if _, err := InstallGitHook(true, isLocalDev()); err != nil { return fmt.Errorf("failed to install git hooks: %w", err) } } return nil } -// IsEmptyRepository returns true if the repository has no commits yet. -// After git-init, HEAD points to an unborn branch (e.g., refs/heads/main) -// whose target does not yet exist. repo.Head() returns ErrReferenceNotFound -// in this case. -func IsEmptyRepository(repo *git.Repository) bool { - _, err := repo.Head() - return errors.Is(err, plumbing.ErrReferenceNotFound) -} - // IsAncestorOf checks if commit is an ancestor of (or equal to) target. // Returns true if target can reach commit by following parent links. // Limits search to 1000 commits to avoid excessive traversal. diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 8a8d2c3c4..6ee98aa55 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -95,30 +95,6 @@ func (s *ManualCommitStrategy) ValidateRepository() error { return nil } -// EnsureSetup ensures the strategy is properly set up. -func (s *ManualCommitStrategy) EnsureSetup() error { - if err := EnsureEntireGitignore(); err != nil { - return err - } - - // Ensure the entire/checkpoints/v1 orphan branch exists for permanent session storage - repo, err := OpenRepository() - if err != nil { - return fmt.Errorf("failed to open git repository: %w", err) - } - if err := EnsureMetadataBranch(repo); err != nil { - return fmt.Errorf("failed to ensure metadata branch: %w", err) - } - - // Install generic hooks (they delegate to strategy at runtime) - if !IsGitHookInstalled() { - if _, err := InstallGitHook(true, isLocalDev()); err != nil { - return fmt.Errorf("failed to install git hooks: %w", err) - } - } - return nil -} - // ListOrphanedItems returns orphaned items created by the manual-commit strategy. // This includes: // - Shadow branches that weren't auto-cleaned during commit condensation From 2d9eaccd6935d170e91b25d6692394305b18c00d Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Wed, 25 Feb 2026 13:42:59 +1100 Subject: [PATCH 07/11] delete all references to multiple strategies --- cmd/entire/cli/doctor.go | 17 +- cmd/entire/cli/explain_test.go | 8 +- cmd/entire/cli/hooks_git_cmd.go | 28 +- .../integration_test/agent_strategy_test.go | 144 ++-- .../cli/integration_test/attribution_test.go | 7 +- .../carry_forward_overlap_test.go | 3 +- .../integration_test/default_branch_test.go | 199 +++--- .../deferred_finalization_test.go | 39 +- .../cli/integration_test/explain_test.go | 245 ++++--- .../cli/integration_test/git_author_test.go | 8 +- .../cli/integration_test/hook_logging_test.go | 4 +- cmd/entire/cli/integration_test/hooks_test.go | 500 +++++++------- .../last_checkpoint_id_test.go | 13 +- .../integration_test/last_interaction_test.go | 193 +++--- .../integration_test/logs_only_rewind_test.go | 16 +- .../manual_commit_untracked_files_test.go | 10 +- .../manual_commit_workflow_test.go | 28 +- .../mid_session_commit_test.go | 10 +- .../mid_session_rebase_test.go | 5 +- .../old_session_basecommit_test.go | 5 +- .../integration_test/opencode_hooks_test.go | 367 +++++----- .../phase_transitions_test.go | 5 +- .../cli/integration_test/resume_test.go | 36 +- .../cli/integration_test/rewind_test.go | 633 +++++++++--------- .../integration_test/session_conflict_test.go | 16 +- .../setup_claude_hooks_test.go | 4 +- .../cli/integration_test/setup_cmd_test.go | 163 +++-- .../setup_gemini_hooks_test.go | 4 +- .../subagent_checkpoints_test.go | 416 ++++++------ .../cli/integration_test/subdirectory_test.go | 241 ++++--- cmd/entire/cli/integration_test/testenv.go | 95 +-- .../cli/integration_test/testenv_test.go | 95 +-- cmd/entire/cli/lifecycle.go | 19 +- cmd/entire/cli/reset.go | 14 +- cmd/entire/cli/resume.go | 95 ++- cmd/entire/cli/rewind.go | 38 +- cmd/entire/cli/root.go | 3 +- cmd/entire/cli/strategy/cleanup.go | 35 +- cmd/entire/cli/strategy/manual_commit.go | 7 +- .../cli/strategy/manual_commit_hooks.go | 6 +- cmd/entire/cli/strategy/manual_commit_logs.go | 3 - cmd/entire/cli/strategy/manual_commit_test.go | 29 - cmd/entire/cli/strategy/registry.go | 52 -- cmd/entire/cli/strategy/rewind_test.go | 46 -- cmd/entire/cli/strategy/session.go | 17 +- cmd/entire/cli/strategy/session_test.go | 6 +- cmd/entire/cli/strategy/strategy.go | 129 +--- cmd/entire/cli/telemetry/detached.go | 7 +- cmd/entire/cli/telemetry/detached_test.go | 8 +- 49 files changed, 1762 insertions(+), 2309 deletions(-) delete mode 100644 cmd/entire/cli/strategy/registry.go diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index e82c7063f..676d21f12 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -98,7 +98,6 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { // Get the current strategy for condense operations strat := GetStrategy() - condenser, canCondense := strat.(strategy.SessionCondenser) fmt.Fprintf(cmd.OutOrStdout(), "Found %d stuck session(s):\n\n", len(stuck)) @@ -106,8 +105,8 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { displayStuckSession(cmd, ss) if force { - if canCondense && ss.HasShadowBranch && ss.CheckpointCount > 0 { - if err := condenser.CondenseSessionByID(ss.State.SessionID); err != nil { + if ss.HasShadowBranch && ss.CheckpointCount > 0 { + if err := strat.CondenseSessionByID(ss.State.SessionID); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to condense session %s: %v\n", ss.State.SessionID, err) } else { fmt.Fprintf(cmd.OutOrStdout(), " -> Condensed session %s\n\n", ss.State.SessionID) @@ -124,7 +123,7 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { } // Interactive: prompt for action - action, err := promptSessionAction(ss, canCondense) + action, err := promptSessionAction(ss) if err != nil { if errors.Is(err, huh.ErrUserAborted) { return nil @@ -134,11 +133,7 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { switch action { case "condense": - if !canCondense { - fmt.Fprintf(cmd.ErrOrStderr(), "Strategy %s does not support condensation\n", strat.Name()) - continue - } - if err := condenser.CondenseSessionByID(ss.State.SessionID); err != nil { + if err := strat.CondenseSessionByID(ss.State.SessionID); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to condense session %s: %v\n", ss.State.SessionID, err) } else { fmt.Fprintf(cmd.OutOrStdout(), " -> Condensed session %s\n\n", ss.State.SessionID) @@ -235,11 +230,11 @@ func displayStuckSession(cmd *cobra.Command, ss stuckSession) { } // promptSessionAction asks the user what to do with a stuck session. -func promptSessionAction(ss stuckSession, canCondense bool) (string, error) { +func promptSessionAction(ss stuckSession) (string, error) { var action string options := make([]huh.Option[string], 0, 3) - if canCondense && ss.HasShadowBranch && ss.CheckpointCount > 0 { + if ss.HasShadowBranch && ss.CheckpointCount > 0 { options = append(options, huh.NewOption("Condense (save to permanent storage)", "condense")) } options = append(options, diff --git a/cmd/entire/cli/explain_test.go b/cmd/entire/cli/explain_test.go index 012d7919c..e5edf6874 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -492,14 +492,8 @@ func TestStrategySessionSourceInterface(t *testing.T) { // This ensures manual-commit strategy implements SessionSource var s = strategy.NewManualCommitStrategy() - // Cast to SessionSource - manual-commit strategy should implement it - source, ok := s.(strategy.SessionSource) - if !ok { - t.Fatal("ManualCommitStrategy should implement SessionSource interface") - } - // GetAdditionalSessions should exist and be callable - _, err := source.GetAdditionalSessions() + _, err := s.GetAdditionalSessions() if err != nil { t.Logf("GetAdditionalSessions returned error: %v", err) } diff --git a/cmd/entire/cli/hooks_git_cmd.go b/cmd/entire/cli/hooks_git_cmd.go index 2655478cd..78b261005 100644 --- a/cmd/entire/cli/hooks_git_cmd.go +++ b/cmd/entire/cli/hooks_git_cmd.go @@ -140,10 +140,8 @@ func newHooksGitPrepareCommitMsgCmd() *cobra.Command { g := newGitHookContext("prepare-commit-msg") g.logInvoked(slog.String("source", source)) - if handler, ok := g.strategy.(strategy.PrepareCommitMsgHandler); ok { - hookErr := handler.PrepareCommitMsg(commitMsgFile, source) - g.logCompleted(hookErr, slog.String("source", source)) - } + hookErr := g.strategy.PrepareCommitMsg(commitMsgFile, source) + g.logCompleted(hookErr, slog.String("source", source)) return nil }, @@ -165,13 +163,9 @@ func newHooksGitCommitMsgCmd() *cobra.Command { g := newGitHookContext("commit-msg") g.logInvoked() - if handler, ok := g.strategy.(strategy.CommitMsgHandler); ok { - hookErr := handler.CommitMsg(commitMsgFile) - g.logCompleted(hookErr) - return hookErr //nolint:wrapcheck // Thin delegation layer - wrapping adds no value - } - - return nil + hookErr := g.strategy.CommitMsg(commitMsgFile) + g.logCompleted(hookErr) + return hookErr //nolint:wrapcheck // Thin delegation layer - wrapping adds no value }, } } @@ -189,10 +183,8 @@ func newHooksGitPostCommitCmd() *cobra.Command { g := newGitHookContext("post-commit") g.logInvoked() - if handler, ok := g.strategy.(strategy.PostCommitHandler); ok { - hookErr := handler.PostCommit() - g.logCompleted(hookErr) - } + hookErr := g.strategy.PostCommit() + g.logCompleted(hookErr) return nil }, @@ -214,10 +206,8 @@ func newHooksGitPrePushCmd() *cobra.Command { g := newGitHookContext("pre-push") g.logInvoked(slog.String("remote", remote)) - if handler, ok := g.strategy.(strategy.PrePushHandler); ok { - hookErr := handler.PrePush(remote) - g.logCompleted(hookErr, slog.String("remote", remote)) - } + hookErr := g.strategy.PrePush(remote) + g.logCompleted(hookErr, slog.String("remote", remote)) return nil }, diff --git a/cmd/entire/cli/integration_test/agent_strategy_test.go b/cmd/entire/cli/integration_test/agent_strategy_test.go index 53a8feb7b..ce7246ba1 100644 --- a/cmd/entire/cli/integration_test/agent_strategy_test.go +++ b/cmd/entire/cli/integration_test/agent_strategy_test.go @@ -11,7 +11,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestAgentStrategyComposition verifies that agent and strategy work together correctly. @@ -19,96 +18,87 @@ import ( func TestAgentStrategyComposition(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Get agent and strategy - ag, err := agent.Get("claude-code") - if err != nil { - t.Fatalf("Get(claude-code) error = %v", err) - } - - _, err = strategy.Get(strategyName) - if err != nil { - t.Fatalf("Get(%s) error = %v", strategyName, err) - } - - // Create a session with the agent - session := env.NewSession() - - // Create test file - env.WriteFile("feature.go", "package main\n// new feature") - - // Create transcript via agent's expected format - transcriptPath := session.CreateTranscript("Add a feature", []FileChange{ - {Path: "feature.go", Content: "package main\n// new feature"}, - }) - - // Read session via agent interface - agentSession, err := ag.ReadSession(&agent.HookInput{ - SessionID: session.ID, - SessionRef: transcriptPath, - }) - if err != nil { - t.Fatalf("ReadSession() error = %v", err) - } - - // Verify agent computed modified files - if len(agentSession.ModifiedFiles) == 0 { - t.Error("agent.ReadSession() should compute ModifiedFiles") - } - - // Simulate session flow: UserPromptSubmit → make changes → Stop - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit error = %v", err) - } - - if err := env.SimulateStop(session.ID, transcriptPath); err != nil { - t.Fatalf("SimulateStop error = %v", err) - } - - // Verify checkpoint was created - points := env.GetRewindPoints() - if len(points) == 0 { - t.Fatal("expected at least 1 rewind point after Stop hook") - } + env := NewFeatureBranchEnv(t) + // Get agent and strategy + ag, err := agent.Get("claude-code") + if err != nil { + t.Fatalf("Get(claude-code) error = %v", err) + } + + // Create a session with the agent + session := env.NewSession() + + // Create test file + env.WriteFile("feature.go", "package main\n// new feature") + + // Create transcript via agent's expected format + transcriptPath := session.CreateTranscript("Add a feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// new feature"}, + }) + + // Read session via agent interface + agentSession, err := ag.ReadSession(&agent.HookInput{ + SessionID: session.ID, + SessionRef: transcriptPath, }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Verify agent computed modified files + if len(agentSession.ModifiedFiles) == 0 { + t.Error("agent.ReadSession() should compute ModifiedFiles") + } + + // Simulate session flow: UserPromptSubmit → make changes → Stop + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit error = %v", err) + } + + if err := env.SimulateStop(session.ID, transcriptPath); err != nil { + t.Fatalf("SimulateStop error = %v", err) + } + + // Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after Stop hook") + } } // TestAgentSessionIDTransformation verifies session ID transformation across agent/strategy boundary. func TestAgentSessionIDTransformation(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create session and simulate full flow - session := env.NewSession() - env.WriteFile("test.go", "package main") - transcriptPath := session.CreateTranscript("Test", []FileChange{ - {Path: "test.go", Content: "package main"}, - }) - - // Simulate hooks - env.SimulateUserPromptSubmit(session.ID) - env.SimulateStop(session.ID, transcriptPath) - - // Get rewind points and verify we can rewind - points := env.GetRewindPoints() - if len(points) == 0 { - t.Skip("no rewind points created") - } - - // Rewind should work - if err := env.Rewind(points[0].ID); err != nil { - t.Errorf("Rewind() error = %v", err) - } + env := NewFeatureBranchEnv(t) + // Create session and simulate full flow + session := env.NewSession() + env.WriteFile("test.go", "package main") + transcriptPath := session.CreateTranscript("Test", []FileChange{ + {Path: "test.go", Content: "package main"}, }) + + // Simulate hooks + env.SimulateUserPromptSubmit(session.ID) + env.SimulateStop(session.ID, transcriptPath) + + // Get rewind points and verify we can rewind + points := env.GetRewindPoints() + if len(points) == 0 { + t.Skip("no rewind points created") + } + + // Rewind should work + if err := env.Rewind(points[0].ID); err != nil { + t.Errorf("Rewind() error = %v", err) + } } // TestAgentTranscriptRestoration verifies transcript is restored correctly on rewind. func TestAgentTranscriptRestoration(t *testing.T) { t.Parallel() - // Only test with manual-commit strategy as it has full transcript restoration - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - + env := NewFeatureBranchEnv(t) ag, _ := agent.Get("claude-code") // Create first session diff --git a/cmd/entire/cli/integration_test/attribution_test.go b/cmd/entire/cli/integration_test/attribution_test.go index 21a8aea49..30da2fde1 100644 --- a/cmd/entire/cli/integration_test/attribution_test.go +++ b/cmd/entire/cli/integration_test/attribution_test.go @@ -9,7 +9,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" @@ -34,7 +33,7 @@ func TestManualCommit_Attribution(t *testing.T) { env.GitAdd("main.go") env.GitCommit("Initial commit") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() initialHead := env.GetHeadHash() t.Logf("Initial HEAD: %s", initialHead[:7]) @@ -225,7 +224,7 @@ func TestManualCommit_AttributionDeletionOnly(t *testing.T) { env.GitAdd("main.go") env.GitCommit("Initial commit") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // ======================================== // CHECKPOINT 1: Agent REMOVES a function (deletion, no additions) @@ -374,7 +373,7 @@ func TestManualCommit_AttributionNoDoubleCount(t *testing.T) { env.GitAdd("main.go") env.GitCommit("Initial commit") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // ======================================== // FIRST CYCLE: Checkpoint → user edit → commit diff --git a/cmd/entire/cli/integration_test/carry_forward_overlap_test.go b/cmd/entire/cli/integration_test/carry_forward_overlap_test.go index 2a48910ef..e27e2cf09 100644 --- a/cmd/entire/cli/integration_test/carry_forward_overlap_test.go +++ b/cmd/entire/cli/integration_test/carry_forward_overlap_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/session" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestCarryForward_NewSessionCommitDoesNotCondenseOldSession verifies that when @@ -42,7 +41,7 @@ func TestCarryForward_NewSessionCommitDoesNotCondenseOldSession(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/multi-session-carry-forward") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // ======================================== // Phase 1: Session 1 creates files, partial commit, ends with carry-forward diff --git a/cmd/entire/cli/integration_test/default_branch_test.go b/cmd/entire/cli/integration_test/default_branch_test.go index 642d5e758..9121a87e0 100644 --- a/cmd/entire/cli/integration_test/default_branch_test.go +++ b/cmd/entire/cli/integration_test/default_branch_test.go @@ -6,116 +6,115 @@ import ( "testing" ) -// TestDefaultBranch_WorksOnMain tests that all strategies work on main branch. +// TestDefaultBranch_WorksOnMain tests that the strategy works on main branch. func TestDefaultBranch_WorksOnMain(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - branch := env.GetCurrentBranch() - if branch != "main" && branch != "master" { - t.Fatalf("expected to be on main or master branch, got %q", branch) - } - - session := env.NewSession() - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - env.WriteFile("file.txt", "content on main") - session.CreateTranscript( - "Add a file", - []FileChange{{Path: "file.txt", Content: "content on main"}}, - ) - - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - points := env.GetRewindPoints() - if len(points) != 1 { - t.Errorf("expected 1 rewind point on main branch for %s strategy, got %d", strategyName, len(points)) - } - }) + env := NewRepoWithCommit(t) + + branch := env.GetCurrentBranch() + if branch != "main" && branch != "master" { + t.Fatalf("expected to be on main or master branch, got %q", branch) + } + + session := env.NewSession() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + env.WriteFile("file.txt", "content on main") + session.CreateTranscript( + "Add a file", + []FileChange{{Path: "file.txt", Content: "content on main"}}, + ) + + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + points := env.GetRewindPoints() + if len(points) != 1 { + t.Errorf("expected 1 rewind point on main branch, got %d", len(points)) + } } // TestDefaultBranch_WorksOnFeatureBranch tests that Entire tracking works on feature branches. func TestDefaultBranch_WorksOnFeatureBranch(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - branch := env.GetCurrentBranch() - if branch != "feature/test-branch" { - t.Fatalf("expected to be on feature/test-branch, got %q", branch) - } - - session := env.NewSession() - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - env.WriteFile("feature.txt", "content on feature branch") - session.CreateTranscript( - "Add a feature file", - []FileChange{{Path: "feature.txt", Content: "content on feature branch"}}, - ) - - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - points := env.GetRewindPoints() - if len(points) != 1 { - t.Errorf("expected 1 rewind point on feature branch, got %d", len(points)) - } - }) + env := NewFeatureBranchEnv(t) + branch := env.GetCurrentBranch() + if branch != "feature/test-branch" { + t.Fatalf("expected to be on feature/test-branch, got %q", branch) + } + + session := env.NewSession() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + env.WriteFile("feature.txt", "content on feature branch") + session.CreateTranscript( + "Add a feature file", + []FileChange{{Path: "feature.txt", Content: "content on feature branch"}}, + ) + + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + points := env.GetRewindPoints() + if len(points) != 1 { + t.Errorf("expected 1 rewind point on feature branch, got %d", len(points)) + } } // TestDefaultBranch_PostTaskWorksOnMain tests that task checkpoints work on main. func TestDefaultBranch_PostTaskWorksOnMain(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - branch := env.GetCurrentBranch() - if branch != "main" && branch != "master" { - t.Fatalf("expected to be on main or master branch, got %q", branch) - } - - session := env.NewSession() - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - session.TranscriptBuilder.AddUserMessage("Create a file using a subagent") - session.TranscriptBuilder.AddAssistantMessage("I'll use the Task tool.") - - taskID := "toolu_task_main" - agentID := "agent_main_xyz" - - session.TranscriptBuilder.AddTaskToolUse(taskID, "Create task.txt") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } - - if err := env.SimulatePreTask(session.ID, session.TranscriptPath, taskID); err != nil { - t.Fatalf("SimulatePreTask failed: %v", err) - } - - env.WriteFile("task.txt", "Created by task on main") - - session.TranscriptBuilder.AddTaskToolResult(taskID, agentID) - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } - - if err := env.SimulatePostTask(PostTaskInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: taskID, - AgentID: agentID, - }); err != nil { - t.Fatalf("SimulatePostTask failed: %v", err) - } - - points := env.GetRewindPoints() - if len(points) != 1 { - t.Errorf("expected 1 rewind point (completed checkpoint) on main for %s, got %d", strategyName, len(points)) - } - }) + env := NewRepoWithCommit(t) + + branch := env.GetCurrentBranch() + if branch != "main" && branch != "master" { + t.Fatalf("expected to be on main or master branch, got %q", branch) + } + + session := env.NewSession() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + session.TranscriptBuilder.AddUserMessage("Create a file using a subagent") + session.TranscriptBuilder.AddAssistantMessage("I'll use the Task tool.") + + taskID := "toolu_task_main" + agentID := "agent_main_xyz" + + session.TranscriptBuilder.AddTaskToolUse(taskID, "Create task.txt") + if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + if err := env.SimulatePreTask(session.ID, session.TranscriptPath, taskID); err != nil { + t.Fatalf("SimulatePreTask failed: %v", err) + } + + env.WriteFile("task.txt", "Created by task on main") + + session.TranscriptBuilder.AddTaskToolResult(taskID, agentID) + if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } + + if err := env.SimulatePostTask(PostTaskInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: taskID, + AgentID: agentID, + }); err != nil { + t.Fatalf("SimulatePostTask failed: %v", err) + } + + points := env.GetRewindPoints() + if len(points) != 1 { + t.Errorf("expected 1 rewind point (completed checkpoint) on main, got %d", len(points)) + } } diff --git a/cmd/entire/cli/integration_test/deferred_finalization_test.go b/cmd/entire/cli/integration_test/deferred_finalization_test.go index 21cb65a4a..5f54cf63f 100644 --- a/cmd/entire/cli/integration_test/deferred_finalization_test.go +++ b/cmd/entire/cli/integration_test/deferred_finalization_test.go @@ -12,7 +12,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" - "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" ) @@ -33,7 +32,7 @@ import ( func TestShadow_DeferredTranscriptFinalization(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -215,7 +214,6 @@ func TestShadow_DeferredTranscriptFinalization(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: checkpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"feature.go"}, ExpectedPrompts: []string{"Create feature function"}, ExpectedTranscriptContent: []string{ @@ -241,7 +239,7 @@ func TestShadow_DeferredTranscriptFinalization(t *testing.T) { func TestShadow_CarryForward_ActiveSession(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -318,7 +316,6 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: firstCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileA.go"}, ExpectedPrompts: []string{"Create files A, B, and C"}, ExpectedTranscriptContent: []string{ @@ -330,7 +327,6 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: secondCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileB.go"}, ExpectedPrompts: []string{"Create files A, B, and C"}, ExpectedTranscriptContent: []string{ @@ -379,7 +375,6 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: thirdCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileC.go"}, ExpectedPrompts: []string{"Create files A, B, and C"}, ExpectedTranscriptContent: []string{ @@ -403,7 +398,7 @@ func TestShadow_CarryForward_ActiveSession(t *testing.T) { func TestShadow_CarryForward_IdleSession(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -478,7 +473,7 @@ func TestShadow_CarryForward_IdleSession(t *testing.T) { func TestShadow_AgentCommitsMidTurn_UserCommitsRemainder(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -562,7 +557,6 @@ func TestShadow_AgentCommitsMidTurn_UserCommitsRemainder(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: userCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileA.go"}, ExpectedPrompts: []string{"Create files A, B, and C"}, }) @@ -571,13 +565,11 @@ func TestShadow_AgentCommitsMidTurn_UserCommitsRemainder(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: firstCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileB.go"}, }) env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: secondCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileC.go"}, }) @@ -604,7 +596,7 @@ func TestShadow_AgentCommitsMidTurn_UserCommitsRemainder(t *testing.T) { func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -703,7 +695,6 @@ func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: cpID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: expectedFiles[i], ExpectedPrompts: []string{"Create files A, B, and C"}, ExpectedTranscriptContent: []string{ @@ -728,7 +719,7 @@ func TestShadow_MultipleCommits_SameActiveTurn(t *testing.T) { func TestShadow_OverlapCheck_UnrelatedCommit(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -785,7 +776,7 @@ func TestShadow_OverlapCheck_UnrelatedCommit(t *testing.T) { func TestShadow_OverlapCheck_PartialOverlap(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -835,7 +826,7 @@ func TestShadow_OverlapCheck_PartialOverlap(t *testing.T) { func TestShadow_SessionDepleted_ManualEditNoCheckpoint(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -925,7 +916,7 @@ func TestShadow_SessionDepleted_ManualEditNoCheckpoint(t *testing.T) { func TestShadow_RevertedFiles_ManualEditNoCheckpoint(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -1002,7 +993,7 @@ func TestShadow_RevertedFiles_ManualEditNoCheckpoint(t *testing.T) { func TestShadow_ResetSession_ClearsTurnCheckpointIDs(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -1088,7 +1079,7 @@ func TestShadow_ResetSession_ClearsTurnCheckpointIDs(t *testing.T) { func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) sess := env.NewSession() @@ -1159,7 +1150,6 @@ func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: firstCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileA.go"}, ExpectedPrompts: []string{"Create files A and B"}, }) @@ -1183,7 +1173,6 @@ func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: secondCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{"fileB.go"}, ExpectedPrompts: []string{"Create files A and B"}, }) @@ -1213,7 +1202,7 @@ func TestShadow_EndedSession_UserCommitsRemainingFiles(t *testing.T) { func TestShadow_DeletedFiles_CheckpointAndCarryForward(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Pre-commit existing files env.WriteFile("old_a.go", "package main\n\nfunc OldA() {}\n") @@ -1277,7 +1266,6 @@ func TestShadow_DeletedFiles_CheckpointAndCarryForward(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: firstCheckpointID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, ExpectedPrompts: []string{"Create new_file.go and delete old_a.go"}, }) @@ -1309,7 +1297,7 @@ func TestShadow_DeletedFiles_CheckpointAndCarryForward(t *testing.T) { func TestShadow_CarryForward_ModifiedExistingFiles(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Pre-commit existing files env.WriteFile("model.go", "package main\n\nfunc Model() {}\n") @@ -1372,7 +1360,6 @@ func TestShadow_CarryForward_ModifiedExistingFiles(t *testing.T) { env.ValidateCheckpoint(CheckpointValidation{ CheckpointID: cpID, SessionID: sess.ID, - Strategy: strategy.StrategyNameManualCommit, FilesTouched: []string{files[i]}, ExpectedPrompts: []string{"Update MVC files"}, }) diff --git a/cmd/entire/cli/integration_test/explain_test.go b/cmd/entire/cli/integration_test/explain_test.go index 9e01a5c06..6e1c496e8 100644 --- a/cmd/entire/cli/integration_test/explain_test.go +++ b/cmd/entire/cli/integration_test/explain_test.go @@ -9,155 +9,148 @@ import ( func TestExplain_NoCurrentSession(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Without any flags, explain shows the branch view (not an error) - output, err := env.RunCLIWithError("explain") - - if err != nil { - t.Errorf("expected success for branch view, got error: %v, output: %s", err, output) - return - } - - // Should show branch information and checkpoint count - if !strings.Contains(output, "Branch:") { - t.Errorf("expected 'Branch:' header in output, got: %s", output) - } - if !strings.Contains(output, "Checkpoints:") { - t.Errorf("expected 'Checkpoints:' in output, got: %s", output) - } - }) + env := NewFeatureBranchEnv(t) + // Without any flags, explain shows the branch view (not an error) + output, err := env.RunCLIWithError("explain") + + if err != nil { + t.Errorf("expected success for branch view, got error: %v, output: %s", err, output) + return + } + + // Should show branch information and checkpoint count + if !strings.Contains(output, "Branch:") { + t.Errorf("expected 'Branch:' header in output, got: %s", output) + } + if !strings.Contains(output, "Checkpoints:") { + t.Errorf("expected 'Checkpoints:' in output, got: %s", output) + } } func TestExplain_SessionFilter(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // --session now filters the list view instead of showing session details - // A nonexistent session ID should show an empty list, not an error - output, err := env.RunCLIWithError("explain", "--session", "nonexistent-session-id") - - if err != nil { - t.Errorf("expected success (empty list) for session filter, got error: %v, output: %s", err, output) - return - } - - // Should show branch header - if !strings.Contains(output, "Branch:") { - t.Errorf("expected 'Branch:' header in output, got: %s", output) - } - - // Should show 0 checkpoints (filter found no matches) - if !strings.Contains(output, "Checkpoints: 0") { - t.Errorf("expected 'Checkpoints: 0' for nonexistent session filter, got: %s", output) - } - - // Should show filter info - if !strings.Contains(output, "Filtered by session:") { - t.Errorf("expected 'Filtered by session:' in output, got: %s", output) - } - }) + env := NewFeatureBranchEnv(t) + // --session now filters the list view instead of showing session details + // A nonexistent session ID should show an empty list, not an error + output, err := env.RunCLIWithError("explain", "--session", "nonexistent-session-id") + + if err != nil { + t.Errorf("expected success (empty list) for session filter, got error: %v, output: %s", err, output) + return + } + + // Should show branch header + if !strings.Contains(output, "Branch:") { + t.Errorf("expected 'Branch:' header in output, got: %s", output) + } + + // Should show 0 checkpoints (filter found no matches) + if !strings.Contains(output, "Checkpoints: 0") { + t.Errorf("expected 'Checkpoints: 0' for nonexistent session filter, got: %s", output) + } + + // Should show filter info + if !strings.Contains(output, "Filtered by session:") { + t.Errorf("expected 'Filtered by session:' in output, got: %s", output) + } } func TestExplain_MutualExclusivity(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Try to provide both --session and --commit flags - output, err := env.RunCLIWithError("explain", "--session", "test-session", "--commit", "abc123") - - if err == nil { - t.Errorf("expected error when both flags provided, got output: %s", output) - return - } - - if !strings.Contains(strings.ToLower(output), "cannot specify multiple") { - t.Errorf("expected 'cannot specify multiple' error, got: %s", output) - } - }) + env := NewFeatureBranchEnv(t) + // Try to provide both --session and --commit flags + output, err := env.RunCLIWithError("explain", "--session", "test-session", "--commit", "abc123") + + if err == nil { + t.Errorf("expected error when both flags provided, got output: %s", output) + return + } + + if !strings.Contains(strings.ToLower(output), "cannot specify multiple") { + t.Errorf("expected 'cannot specify multiple' error, got: %s", output) + } } func TestExplain_CheckpointNotFound(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Try to explain a non-existent checkpoint - output, err := env.RunCLIWithError("explain", "--checkpoint", "nonexistent123") - - if err == nil { - t.Errorf("expected error for nonexistent checkpoint, got output: %s", output) - return - } - - if !strings.Contains(output, "checkpoint not found") { - t.Errorf("expected 'checkpoint not found' error, got: %s", output) - } - }) + env := NewFeatureBranchEnv(t) + // Try to explain a non-existent checkpoint + output, err := env.RunCLIWithError("explain", "--checkpoint", "nonexistent123") + + if err == nil { + t.Errorf("expected error for nonexistent checkpoint, got output: %s", output) + return + } + + if !strings.Contains(output, "checkpoint not found") { + t.Errorf("expected 'checkpoint not found' error, got: %s", output) + } } func TestExplain_CheckpointMutualExclusivity(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Try to provide --checkpoint with --session - output, err := env.RunCLIWithError("explain", "--session", "test-session", "--checkpoint", "abc123") - - if err == nil { - t.Errorf("expected error when both flags provided, got output: %s", output) - return - } - - if !strings.Contains(strings.ToLower(output), "cannot specify multiple") { - t.Errorf("expected 'cannot specify multiple' error, got: %s", output) - } - }) + env := NewFeatureBranchEnv(t) + // Try to provide --checkpoint with --session + output, err := env.RunCLIWithError("explain", "--session", "test-session", "--checkpoint", "abc123") + + if err == nil { + t.Errorf("expected error when both flags provided, got output: %s", output) + return + } + + if !strings.Contains(strings.ToLower(output), "cannot specify multiple") { + t.Errorf("expected 'cannot specify multiple' error, got: %s", output) + } } func TestExplain_CommitWithoutCheckpoint(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a regular commit without Entire-Checkpoint trailer - env.WriteFile("test.txt", "content") - env.GitAdd("test.txt") - env.GitCommit("Regular commit without Entire trailer") - - // Get the commit hash - commitHash := env.GetHeadHash() - - // Run explain --commit - output, err := env.RunCLIWithError("explain", "--commit", commitHash[:7]) - if err != nil { - t.Fatalf("unexpected error: %v, output: %s", err, output) - } - - // Should show "No associated Entire checkpoint" message - if !strings.Contains(output, "No associated Entire checkpoint") { - t.Errorf("expected 'No associated Entire checkpoint' message, got: %s", output) - } - }) + env := NewFeatureBranchEnv(t) + // Create a regular commit without Entire-Checkpoint trailer + env.WriteFile("test.txt", "content") + env.GitAdd("test.txt") + env.GitCommit("Regular commit without Entire trailer") + + // Get the commit hash + commitHash := env.GetHeadHash() + + // Run explain --commit + output, err := env.RunCLIWithError("explain", "--commit", commitHash[:7]) + if err != nil { + t.Fatalf("unexpected error: %v, output: %s", err, output) + } + + // Should show "No associated Entire checkpoint" message + if !strings.Contains(output, "No associated Entire checkpoint") { + t.Errorf("expected 'No associated Entire checkpoint' message, got: %s", output) + } } func TestExplain_CommitWithCheckpointTrailer(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a commit with Entire-Checkpoint trailer - env.WriteFile("test.txt", "content") - env.GitAdd("test.txt") - env.GitCommitWithCheckpointID("Commit with checkpoint", "abc123def456") - - // Get the commit hash - commitHash := env.GetHeadHash() - - // Run explain --commit - it should try to look up the checkpoint - // Since the checkpoint doesn't actually exist in the store, it should error - output, err := env.RunCLIWithError("explain", "--commit", commitHash[:7]) - - // We expect an error because the checkpoint abc123def456 doesn't exist - if err == nil { - // If it succeeded, check if it found the checkpoint (it shouldn't) - if strings.Contains(output, "Checkpoint:") { - t.Logf("checkpoint was found (unexpected but ok if test created one)") - } - } else { - // Expected: checkpoint not found error - if !strings.Contains(output, "checkpoint not found") { - t.Errorf("expected 'checkpoint not found' error, got: %s", output) - } + env := NewFeatureBranchEnv(t) + // Create a commit with Entire-Checkpoint trailer + env.WriteFile("test.txt", "content") + env.GitAdd("test.txt") + env.GitCommitWithCheckpointID("Commit with checkpoint", "abc123def456") + + // Get the commit hash + commitHash := env.GetHeadHash() + + // Run explain --commit - it should try to look up the checkpoint + // Since the checkpoint doesn't actually exist in the store, it should error + output, err := env.RunCLIWithError("explain", "--commit", commitHash[:7]) + + // We expect an error because the checkpoint abc123def456 doesn't exist + if err == nil { + // If it succeeded, check if it found the checkpoint (it shouldn't) + if strings.Contains(output, "Checkpoint:") { + t.Logf("checkpoint was found (unexpected but ok if test created one)") + } + } else { + // Expected: checkpoint not found error + if !strings.Contains(output, "checkpoint not found") { + t.Errorf("expected 'checkpoint not found' error, got: %s", output) } - }) + } } diff --git a/cmd/entire/cli/integration_test/git_author_test.go b/cmd/entire/cli/integration_test/git_author_test.go index bde5191b0..55b7760ef 100644 --- a/cmd/entire/cli/integration_test/git_author_test.go +++ b/cmd/entire/cli/integration_test/git_author_test.go @@ -17,7 +17,7 @@ import ( func TestGetGitAuthorWithLocalConfig(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, "manual-commit") + env := NewFeatureBranchEnv(t) // Local config is set by InitRepo(), so this tests the normal case env.WriteFile("test.txt", "content") @@ -66,7 +66,7 @@ func TestGetGitAuthorFallbackToGitCommand(t *testing.T) { // The repo now has no local user config. We'll use GIT_AUTHOR_* and GIT_COMMITTER_* // env vars for commits, simulating global config that go-git can't see but git command can. - env.InitEntire("manual-commit") + env.InitEntire() // Create initial commit using environment variables for author/committer env.WriteFile("README.md", "# Test") @@ -150,7 +150,7 @@ func TestGetGitAuthorNoConfigReturnsDefaults(t *testing.T) { t.Fatalf("git config commit.gpgsign failed: %v", err) } - env.InitEntire("manual-commit") + env.InitEntire() // Create initial commit using environment variables (required for CI without global config) env.WriteFile("README.md", "# Test") @@ -237,7 +237,7 @@ func TestGetGitAuthorRemovingLocalConfig(t *testing.T) { t.Fatalf("failed to write .git/config: %v", err) } - env.InitEntire("manual-commit") + env.InitEntire() // Need to create initial commit - use environment variables (works in CI without global config) env.WriteFile("README.md", "# Test") diff --git a/cmd/entire/cli/integration_test/hook_logging_test.go b/cmd/entire/cli/integration_test/hook_logging_test.go index d3db5c359..ee2b6509f 100644 --- a/cmd/entire/cli/integration_test/hook_logging_test.go +++ b/cmd/entire/cli/integration_test/hook_logging_test.go @@ -20,7 +20,7 @@ func TestHookLogging_WritesToSessionLogFile(t *testing.T) { env := NewTestEnv(t) env.InitRepo() - env.InitEntire("manual-commit") // Use manual-commit strategy (doesn't matter for logging) + env.InitEntire() // Create a session state file in .git/entire-sessions/ with a known session ID sessionID := "test-logging-session-123" @@ -86,7 +86,7 @@ func TestHookLogging_WritesWithoutSession(t *testing.T) { env := NewTestEnv(t) env.InitRepo() - env.InitEntire("manual-commit") + env.InitEntire() // Don't create a session state file - logging should still write to entire.log diff --git a/cmd/entire/cli/integration_test/hooks_test.go b/cmd/entire/cli/integration_test/hooks_test.go index 9726a9fa4..0e952114c 100644 --- a/cmd/entire/cli/integration_test/hooks_test.go +++ b/cmd/entire/cli/integration_test/hooks_test.go @@ -12,59 +12,54 @@ import ( func TestHookRunner_SimulateUserPromptSubmit(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create an untracked file to capture - env.WriteFile("newfile.txt", "content") - - modelSessionID := "test-session-1" - err := env.SimulateUserPromptSubmit(modelSessionID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - // Verify pre-prompt state was captured (uses entire session ID with date prefix) - statePath := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-prompt-"+modelSessionID+".json") - if _, err := os.Stat(statePath); os.IsNotExist(err) { - t.Error("pre-prompt state file should exist") - } - }) + env := NewRepoWithCommit(t) + // Create an untracked file to capture + env.WriteFile("newfile.txt", "content") + + modelSessionID := "test-session-1" + err := env.SimulateUserPromptSubmit(modelSessionID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Verify pre-prompt state was captured (uses entire session ID with date prefix) + statePath := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-prompt-"+modelSessionID+".json") + if _, err := os.Stat(statePath); os.IsNotExist(err) { + t.Error("pre-prompt state file should exist") + } } func TestHookRunner_SimulateStop(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a session - session := env.NewSession() - - // Simulate user prompt submit first - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - // Create a file (as if Claude Code wrote it) - env.WriteFile("created.txt", "created by claude") - - // Create transcript - session.CreateTranscript("Create a file", []FileChange{ - {Path: "created.txt", Content: "created by claude"}, - }) - - // Simulate stop - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - // Verify a commit was created (check git log) - skip for manual-commit strategy - // manual-commit strategy doesn't create commits on the main branch - if strategyName != "manual-commit" { - hash := env.GetHeadHash() - if len(hash) != 40 { - t.Errorf("expected valid commit hash, got %s", hash) - } - } + env := NewRepoWithCommit(t) + // Create a session + session := env.NewSession() + + // Simulate user prompt submit first + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Create a file (as if Claude Code wrote it) + env.WriteFile("created.txt", "created by claude") + + // Create transcript + session.CreateTranscript("Create a file", []FileChange{ + {Path: "created.txt", Content: "created by claude"}, }) + + // Simulate stop + err = env.SimulateStop(session.ID, session.TranscriptPath) + if err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Verify a commit was created (check git log) + hash := env.GetHeadHash() + if len(hash) != 40 { + t.Errorf("expected valid commit hash, got %s", hash) + } } // TestHookRunner_SimulateStop_AlreadyCommitted tests that the stop hook handles @@ -72,57 +67,55 @@ func TestHookRunner_SimulateStop(t *testing.T) { // by the user before the hook runs. This should not fail. func TestHookRunner_SimulateStop_AlreadyCommitted(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a session - session := env.NewSession() - - // Simulate user prompt submit first (captures pre-prompt state) - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - // Create a file (as if Claude Code wrote it) - env.WriteFile("created.txt", "created by claude") - - // USER COMMITS THE FILE BEFORE HOOK RUNS - // This simulates the scenario where user runs `git commit` manually - // or the changes are committed via another mechanism - env.GitAdd("created.txt") - env.GitCommit("User committed changes manually") - - // Create transcript (still references the file as modified during session) - session.CreateTranscript("Create a file", []FileChange{ - {Path: "created.txt", Content: "created by claude"}, - }) - - // Simulate stop - this should NOT fail even though file is already committed - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("SimulateStop should handle already-committed files gracefully, got error: %v", err) - } + env := NewRepoWithCommit(t) + // Create a session + session := env.NewSession() + + // Simulate user prompt submit first (captures pre-prompt state) + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Create a file (as if Claude Code wrote it) + env.WriteFile("created.txt", "created by claude") + + // USER COMMITS THE FILE BEFORE HOOK RUNS + // This simulates the scenario where user runs `git commit` manually + // or the changes are committed via another mechanism + env.GitAdd("created.txt") + env.GitCommit("User committed changes manually") + + // Create transcript (still references the file as modified during session) + session.CreateTranscript("Create a file", []FileChange{ + {Path: "created.txt", Content: "created by claude"}, }) + + // Simulate stop - this should NOT fail even though file is already committed + err = env.SimulateStop(session.ID, session.TranscriptPath) + if err != nil { + t.Fatalf("SimulateStop should handle already-committed files gracefully, got error: %v", err) + } } func TestSession_CreateTranscript(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - session := env.NewSession() - transcriptPath := session.CreateTranscript("Test prompt", []FileChange{ - {Path: "file1.txt", Content: "content1"}, - {Path: "file2.txt", Content: "content2"}, - }) - - // Verify transcript file exists - if _, err := os.Stat(transcriptPath); os.IsNotExist(err) { - t.Error("transcript file should exist") - } - - // Verify session ID format - if session.ID != "test-session-1" { - t.Errorf("session ID = %s, want test-session-1", session.ID) - } + env := NewRepoWithCommit(t) + session := env.NewSession() + transcriptPath := session.CreateTranscript("Test prompt", []FileChange{ + {Path: "file1.txt", Content: "content1"}, + {Path: "file2.txt", Content: "content2"}, }) + + // Verify transcript file exists + if _, err := os.Stat(transcriptPath); os.IsNotExist(err) { + t.Error("transcript file should exist") + } + + // Verify session ID format + if session.ID != "test-session-1" { + t.Errorf("session ID = %s, want test-session-1", session.ID) + } } // TestHookRunner_SimulateStop_SubagentOnlyChanges tests that the stop hook correctly @@ -132,82 +125,81 @@ func TestSession_CreateTranscript(t *testing.T) { // The fix (ExtractAllModifiedFiles) also reads subagent transcript files. func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a session - session := env.NewSession() - - // Simulate user prompt submit first (captures pre-prompt state) - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - // Create a file on disk (simulating what a subagent would write) - env.WriteFile("subagent_output.go", "package main\n\nfunc SubagentWork() {}\n") - - // Build the main transcript manually. The main transcript contains ONLY - // a Task tool call (no Write/Edit). All file modifications happened in - // the subagent. - mainTranscript := NewTranscriptBuilder() - mainTranscript.AddUserMessage("Create a function in a new file") - mainTranscript.AddAssistantMessage("I'll delegate this to a subagent.") - - // Add Task tool use - taskToolUseID := mainTranscript.AddTaskToolUse("", "Create the function") - - // Add Task tool result with agentId - agentID := "sub123abc" - mainTranscript.AddTaskToolResult(taskToolUseID, agentID) - - mainTranscript.AddAssistantMessage("The subagent completed the task.") - - // Write the main transcript - if err := mainTranscript.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write main transcript: %v", err) - } - - // Create the subagent transcript file in the directory structure the Stop hook expects: - // //subagents/agent-.jsonl - transcriptDir := filepath.Dir(session.TranscriptPath) - subagentsDir := filepath.Join(transcriptDir, session.ID, "subagents") - subagentTranscriptPath := filepath.Join(subagentsDir, "agent-"+agentID+".jsonl") - - // Build the subagent transcript with Write tool calls - subagentTranscript := NewTranscriptBuilder() - subagentTranscript.AddUserMessage("Create a function in a new file") - absFilePath := filepath.Join(env.RepoDir, "subagent_output.go") - toolID := subagentTranscript.AddToolUse("Write", absFilePath, "package main\n\nfunc SubagentWork() {}\n") - subagentTranscript.AddToolResult(toolID) - subagentTranscript.AddAssistantMessage("Done, I created the function.") - - if err := subagentTranscript.WriteToFile(subagentTranscriptPath); err != nil { - t.Fatalf("failed to write subagent transcript: %v", err) - } - - // Simulate stop - this should NOT error and should create a checkpoint - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - // Verify checkpoint was created (manual-commit stores checkpoint data on the shadow branch) - shadowBranch := env.GetShadowBranchName() - if !env.BranchExists(shadowBranch) { - t.Errorf("shadow branch %s should exist after checkpoint", shadowBranch) - } - - // Verify session state was updated with checkpoint count - state, stateErr := env.GetSessionState(session.ID) - if stateErr != nil { - t.Fatalf("failed to get session state: %v", stateErr) - } - if state == nil { - t.Fatal("session state should exist after checkpoint") - } - if state.StepCount == 0 { - t.Error("session state should have non-zero step count") - } - }) + env := NewRepoWithCommit(t) + // Create a session + session := env.NewSession() + + // Simulate user prompt submit first (captures pre-prompt state) + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + // Create a file on disk (simulating what a subagent would write) + env.WriteFile("subagent_output.go", "package main\n\nfunc SubagentWork() {}\n") + + // Build the main transcript manually. The main transcript contains ONLY + // a Task tool call (no Write/Edit). All file modifications happened in + // the subagent. + mainTranscript := NewTranscriptBuilder() + mainTranscript.AddUserMessage("Create a function in a new file") + mainTranscript.AddAssistantMessage("I'll delegate this to a subagent.") + + // Add Task tool use + taskToolUseID := mainTranscript.AddTaskToolUse("", "Create the function") + + // Add Task tool result with agentId + agentID := "sub123abc" + mainTranscript.AddTaskToolResult(taskToolUseID, agentID) + + mainTranscript.AddAssistantMessage("The subagent completed the task.") + + // Write the main transcript + if err := mainTranscript.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write main transcript: %v", err) + } + + // Create the subagent transcript file in the directory structure the Stop hook expects: + // //subagents/agent-.jsonl + transcriptDir := filepath.Dir(session.TranscriptPath) + subagentsDir := filepath.Join(transcriptDir, session.ID, "subagents") + subagentTranscriptPath := filepath.Join(subagentsDir, "agent-"+agentID+".jsonl") + + // Build the subagent transcript with Write tool calls + subagentTranscript := NewTranscriptBuilder() + subagentTranscript.AddUserMessage("Create a function in a new file") + absFilePath := filepath.Join(env.RepoDir, "subagent_output.go") + toolID := subagentTranscript.AddToolUse("Write", absFilePath, "package main\n\nfunc SubagentWork() {}\n") + subagentTranscript.AddToolResult(toolID) + subagentTranscript.AddAssistantMessage("Done, I created the function.") + + if err := subagentTranscript.WriteToFile(subagentTranscriptPath); err != nil { + t.Fatalf("failed to write subagent transcript: %v", err) + } + + // Simulate stop - this should NOT error and should create a checkpoint + err = env.SimulateStop(session.ID, session.TranscriptPath) + if err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + // Verify checkpoint was created (manual-commit stores checkpoint data on the shadow branch) + shadowBranch := env.GetShadowBranchName() + if !env.BranchExists(shadowBranch) { + t.Errorf("shadow branch %s should exist after checkpoint", shadowBranch) + } + + // Verify session state was updated with checkpoint count + state, stateErr := env.GetSessionState(session.ID) + if stateErr != nil { + t.Fatalf("failed to get session state: %v", stateErr) + } + if state == nil { + t.Fatal("session state should exist after checkpoint") + } + if state.StepCount == 0 { + t.Error("session state should have non-zero step count") + } } // TestUserPromptSubmit_ReinstallsOverwrittenHooks verifies that EnsureSetup is called @@ -216,99 +208,97 @@ func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { // mid-turn commits the agent might make. func TestUserPromptSubmit_ReinstallsOverwrittenHooks(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - hooksDir := filepath.Join(env.RepoDir, ".git", "hooks") - hookNames := strategy.ManagedGitHookNames() - - // Step 1: First user-prompt-submit installs hooks via EnsureSetup - session := env.NewSession() - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) - } - - // Verify hooks are now installed - if !strategy.IsGitHookInstalledInDir(env.RepoDir) { - t.Fatal("hooks should be installed after first SimulateUserPromptSubmit") - } - - // Step 2: Overwrite hooks with third-party content (simulating lefthook, husky, etc.) - for _, hookName := range hookNames { - hookPath := filepath.Join(hooksDir, hookName) - thirdPartyContent := "#!/bin/sh\n# Third-party hook manager\necho 'Running third-party hook'\n" - if err := os.WriteFile(hookPath, []byte(thirdPartyContent), 0o755); err != nil { - t.Fatalf("failed to overwrite hook %s: %v", hookName, err) - } - } - - // Step 3: Verify hooks are no longer Entire hooks - if strategy.IsGitHookInstalledInDir(env.RepoDir) { - t.Fatal("hooks should NOT be detected as Entire hooks after overwrite") + env := NewRepoWithCommit(t) + hooksDir := filepath.Join(env.RepoDir, ".git", "hooks") + hookNames := strategy.ManagedGitHookNames() + + // Step 1: First user-prompt-submit installs hooks via EnsureSetup + session := env.NewSession() + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) + } + + // Verify hooks are now installed + if !strategy.IsGitHookInstalledInDir(env.RepoDir) { + t.Fatal("hooks should be installed after first SimulateUserPromptSubmit") + } + + // Step 2: Overwrite hooks with third-party content (simulating lefthook, husky, etc.) + for _, hookName := range hookNames { + hookPath := filepath.Join(hooksDir, hookName) + thirdPartyContent := "#!/bin/sh\n# Third-party hook manager\necho 'Running third-party hook'\n" + if err := os.WriteFile(hookPath, []byte(thirdPartyContent), 0o755); err != nil { + t.Fatalf("failed to overwrite hook %s: %v", hookName, err) } - - // Step 4: Second user-prompt-submit should reinstall hooks - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) + } + + // Step 3: Verify hooks are no longer Entire hooks + if strategy.IsGitHookInstalledInDir(env.RepoDir) { + t.Fatal("hooks should NOT be detected as Entire hooks after overwrite") + } + + // Step 4: Second user-prompt-submit should reinstall hooks + err = env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) + } + + // Step 5: Verify hooks are reinstalled + if !strategy.IsGitHookInstalledInDir(env.RepoDir) { + t.Error("hooks should be reinstalled after second SimulateUserPromptSubmit") + } + + // Step 6: Verify the hooks chain to original hooks (backup should exist) + for _, hookName := range hookNames { + backupPath := filepath.Join(hooksDir, hookName+".pre-entire") + if _, err := os.Stat(backupPath); os.IsNotExist(err) { + t.Errorf("backup hook %s.pre-entire should exist", hookName) } - - // Step 5: Verify hooks are reinstalled - if !strategy.IsGitHookInstalledInDir(env.RepoDir) { - t.Error("hooks should be reinstalled after second SimulateUserPromptSubmit") - } - - // Step 6: Verify the hooks chain to original hooks (backup should exist) - for _, hookName := range hookNames { - backupPath := filepath.Join(hooksDir, hookName+".pre-entire") - if _, err := os.Stat(backupPath); os.IsNotExist(err) { - t.Errorf("backup hook %s.pre-entire should exist", hookName) - } - } - }) + } } // TestUserPromptSubmit_ReinstallsDeletedHooks verifies that EnsureSetup reinstalls // hooks that were completely deleted by third-party tools. func TestUserPromptSubmit_ReinstallsDeletedHooks(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - hooksDir := filepath.Join(env.RepoDir, ".git", "hooks") - hookNames := strategy.ManagedGitHookNames() - - // Step 1: First user-prompt-submit installs hooks via EnsureSetup - session := env.NewSession() - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) - } - - // Verify hooks are now installed - if !strategy.IsGitHookInstalledInDir(env.RepoDir) { - t.Fatal("hooks should be installed after first SimulateUserPromptSubmit") - } - - // Step 2: Delete all hooks (simulating aggressive third-party tool) - for _, hookName := range hookNames { - hookPath := filepath.Join(hooksDir, hookName) - if err := os.Remove(hookPath); err != nil && !os.IsNotExist(err) { - t.Fatalf("failed to delete hook %s: %v", hookName, err) - } - } - - // Step 3: Verify hooks are gone - if strategy.IsGitHookInstalledInDir(env.RepoDir) { - t.Fatal("hooks should NOT be detected after deletion") - } - - // Step 4: Second user-prompt-submit should reinstall hooks - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) - } - - // Step 5: Verify hooks are reinstalled - if !strategy.IsGitHookInstalledInDir(env.RepoDir) { - t.Error("hooks should be reinstalled after second SimulateUserPromptSubmit") + env := NewRepoWithCommit(t) + hooksDir := filepath.Join(env.RepoDir, ".git", "hooks") + hookNames := strategy.ManagedGitHookNames() + + // Step 1: First user-prompt-submit installs hooks via EnsureSetup + session := env.NewSession() + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) + } + + // Verify hooks are now installed + if !strategy.IsGitHookInstalledInDir(env.RepoDir) { + t.Fatal("hooks should be installed after first SimulateUserPromptSubmit") + } + + // Step 2: Delete all hooks (simulating aggressive third-party tool) + for _, hookName := range hookNames { + hookPath := filepath.Join(hooksDir, hookName) + if err := os.Remove(hookPath); err != nil && !os.IsNotExist(err) { + t.Fatalf("failed to delete hook %s: %v", hookName, err) } - }) + } + + // Step 3: Verify hooks are gone + if strategy.IsGitHookInstalledInDir(env.RepoDir) { + t.Fatal("hooks should NOT be detected after deletion") + } + + // Step 4: Second user-prompt-submit should reinstall hooks + err = env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) + } + + // Step 5: Verify hooks are reinstalled + if !strategy.IsGitHookInstalledInDir(env.RepoDir) { + t.Error("hooks should be reinstalled after second SimulateUserPromptSubmit") + } } diff --git a/cmd/entire/cli/integration_test/last_checkpoint_id_test.go b/cmd/entire/cli/integration_test/last_checkpoint_id_test.go index 22ca55856..a1f7bc348 100644 --- a/cmd/entire/cli/integration_test/last_checkpoint_id_test.go +++ b/cmd/entire/cli/integration_test/last_checkpoint_id_test.go @@ -8,7 +8,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestShadowStrategy_OneCheckpointPerCommit tests the 1:1 checkpoint model: @@ -24,7 +23,7 @@ import ( func TestShadowStrategy_OneCheckpointPerCommit(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() @@ -93,7 +92,7 @@ func TestShadowStrategy_OneCheckpointPerCommit(t *testing.T) { func TestShadowStrategy_LastCheckpointID_ClearedOnNewPrompt(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // === First session work === session1 := env.NewSession() @@ -178,7 +177,7 @@ func TestShadowStrategy_LastCheckpointID_ClearedOnNewPrompt(t *testing.T) { func TestShadowStrategy_LastCheckpointID_NotSetWithoutCondensation(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a file directly (not through a Claude session) env.WriteFile("manual.txt", "manual content") @@ -201,7 +200,7 @@ func TestShadowStrategy_LastCheckpointID_NotSetWithoutCondensation(t *testing.T) func TestShadowStrategy_NewSessionIgnoresOldCheckpointIDs(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // === Create an OLD session on the initial commit === oldSession := env.NewSession() @@ -266,7 +265,7 @@ func TestShadowStrategy_NewSessionIgnoresOldCheckpointIDs(t *testing.T) { func TestShadowStrategy_ShadowBranchCleanedUpAfterCondensation(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() @@ -318,7 +317,7 @@ func TestShadowStrategy_ShadowBranchCleanedUpAfterCondensation(t *testing.T) { func TestShadowStrategy_BaseCommitUpdatedAfterCondensation(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() diff --git a/cmd/entire/cli/integration_test/last_interaction_test.go b/cmd/entire/cli/integration_test/last_interaction_test.go index b42b02db4..4d2beb18b 100644 --- a/cmd/entire/cli/integration_test/last_interaction_test.go +++ b/cmd/entire/cli/integration_test/last_interaction_test.go @@ -11,115 +11,112 @@ import ( // when a session is first initialized via UserPromptSubmit. func TestLastInteractionTime_SetOnFirstPrompt(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, _ string) { - session := env.NewSession() - - beforePrompt := time.Now() - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - state, err := env.GetSessionState(session.ID) - if err != nil { - t.Fatalf("GetSessionState failed: %v", err) - } - if state == nil { - t.Fatal("session state should exist after UserPromptSubmit") - } - - if state.LastInteractionTime == nil { - t.Fatal("LastInteractionTime should be set after first prompt") - } - if state.LastInteractionTime.Before(beforePrompt) { - t.Errorf("LastInteractionTime %v should be after test start %v", - *state.LastInteractionTime, beforePrompt) - } - }) + env := NewRepoWithCommit(t) + session := env.NewSession() + + beforePrompt := time.Now() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + state, err := env.GetSessionState(session.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if state == nil { + t.Fatal("session state should exist after UserPromptSubmit") + } + + if state.LastInteractionTime == nil { + t.Fatal("LastInteractionTime should be set after first prompt") + } + if state.LastInteractionTime.Before(beforePrompt) { + t.Errorf("LastInteractionTime %v should be after test start %v", + *state.LastInteractionTime, beforePrompt) + } } // TestLastInteractionTime_UpdatedOnSubsequentPrompts verifies that LastInteractionTime // is updated on each subsequent UserPromptSubmit call. func TestLastInteractionTime_UpdatedOnSubsequentPrompts(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, _ string) { - session := env.NewSession() - - // First prompt - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("first SimulateUserPromptSubmit failed: %v", err) - } - - state1, err := env.GetSessionState(session.ID) - if err != nil { - t.Fatalf("GetSessionState after first prompt failed: %v", err) - } - if state1.LastInteractionTime == nil { - t.Fatal("LastInteractionTime should be set after first prompt") - } - firstInteraction := *state1.LastInteractionTime - - // Small delay to ensure timestamps differ - time.Sleep(10 * time.Millisecond) - - // Second prompt - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("second SimulateUserPromptSubmit failed: %v", err) - } - - state2, err := env.GetSessionState(session.ID) - if err != nil { - t.Fatalf("GetSessionState after second prompt failed: %v", err) - } - if state2.LastInteractionTime == nil { - t.Fatal("LastInteractionTime should be set after second prompt") - } - - if !state2.LastInteractionTime.After(firstInteraction) { - t.Errorf("LastInteractionTime should be updated: first=%v, second=%v", - firstInteraction, *state2.LastInteractionTime) - } - }) + env := NewRepoWithCommit(t) + session := env.NewSession() + + // First prompt + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("first SimulateUserPromptSubmit failed: %v", err) + } + + state1, err := env.GetSessionState(session.ID) + if err != nil { + t.Fatalf("GetSessionState after first prompt failed: %v", err) + } + if state1.LastInteractionTime == nil { + t.Fatal("LastInteractionTime should be set after first prompt") + } + firstInteraction := *state1.LastInteractionTime + + // Small delay to ensure timestamps differ + time.Sleep(10 * time.Millisecond) + + // Second prompt + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("second SimulateUserPromptSubmit failed: %v", err) + } + + state2, err := env.GetSessionState(session.ID) + if err != nil { + t.Fatalf("GetSessionState after second prompt failed: %v", err) + } + if state2.LastInteractionTime == nil { + t.Fatal("LastInteractionTime should be set after second prompt") + } + + if !state2.LastInteractionTime.After(firstInteraction) { + t.Errorf("LastInteractionTime should be updated: first=%v, second=%v", + firstInteraction, *state2.LastInteractionTime) + } } // TestLastInteractionTime_PreservedAcrossCheckpoints verifies that LastInteractionTime // survives a full checkpoint cycle (prompt → stop → prompt). func TestLastInteractionTime_PreservedAcrossCheckpoints(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, _ string) { - session := env.NewSession() - - // First prompt + checkpoint - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - env.WriteFile("file1.txt", "content1") - session.CreateTranscript("Create file1", []FileChange{ - {Path: "file1.txt", Content: "content1"}, - }) - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - time.Sleep(10 * time.Millisecond) - - // Second prompt - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("second SimulateUserPromptSubmit failed: %v", err) - } - - state, err := env.GetSessionState(session.ID) - if err != nil { - t.Fatalf("GetSessionState failed: %v", err) - } - if state.LastInteractionTime == nil { - t.Fatal("LastInteractionTime should be set after second prompt") - } - - // LastInteractionTime should be after StartedAt (second prompt is later) - if !state.LastInteractionTime.After(state.StartedAt) { - t.Errorf("LastInteractionTime %v should be after StartedAt %v", - *state.LastInteractionTime, state.StartedAt) - } + env := NewRepoWithCommit(t) + session := env.NewSession() + + // First prompt + checkpoint + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } + + env.WriteFile("file1.txt", "content1") + session.CreateTranscript("Create file1", []FileChange{ + {Path: "file1.txt", Content: "content1"}, }) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } + + time.Sleep(10 * time.Millisecond) + + // Second prompt + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("second SimulateUserPromptSubmit failed: %v", err) + } + + state, err := env.GetSessionState(session.ID) + if err != nil { + t.Fatalf("GetSessionState failed: %v", err) + } + if state.LastInteractionTime == nil { + t.Fatal("LastInteractionTime should be set after second prompt") + } + + // LastInteractionTime should be after StartedAt (second prompt is later) + if !state.LastInteractionTime.After(state.StartedAt) { + t.Errorf("LastInteractionTime %v should be after StartedAt %v", + *state.LastInteractionTime, state.StartedAt) + } } diff --git a/cmd/entire/cli/integration_test/logs_only_rewind_test.go b/cmd/entire/cli/integration_test/logs_only_rewind_test.go index e7ac26bf3..2d63cfc40 100644 --- a/cmd/entire/cli/integration_test/logs_only_rewind_test.go +++ b/cmd/entire/cli/integration_test/logs_only_rewind_test.go @@ -7,8 +7,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestLogsOnlyRewind_AppearsInRewindList verifies that after user commits with @@ -29,7 +27,7 @@ func TestLogsOnlyRewind_AppearsInRewindList(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/logs-only-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create session and checkpoint") @@ -117,7 +115,7 @@ func TestLogsOnlyRewind_RestoresTranscript(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/logs-restore-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create session and commit") @@ -224,7 +222,7 @@ func TestLogsOnlyRewind_DoesNotModifyWorkingDirectory(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/no-modify-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create session 1 and commit") @@ -327,7 +325,7 @@ func TestLogsOnlyRewind_DeduplicationWithCheckpoints(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/dedup-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create checkpoint (but don't commit yet)") @@ -407,7 +405,7 @@ func TestLogsOnlyRewind_MultipleCommits(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/multi-commit-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() var commitHashes []string @@ -481,7 +479,7 @@ func TestLogsOnlyRewind_Reset(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/reset-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create session 1 and commit") @@ -586,7 +584,7 @@ func TestLogsOnlyRewind_ResetRestoresTranscript(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/reset-transcript-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create session and commit") diff --git a/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go b/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go index 06ab4d15c..17addd7b4 100644 --- a/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go +++ b/cmd/entire/cli/integration_test/manual_commit_untracked_files_test.go @@ -7,8 +7,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestShadow_UntrackedFilePreservation tests that untracked files present at session start @@ -44,7 +42,7 @@ func TestShadow_UntrackedFilePreservation(t *testing.T) { env.WriteFile(".env.local", "DEBUG=true") // Initialize Entire AFTER creating untracked files - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() initialHead := env.GetHeadHash() t.Logf("Initial HEAD on feature/work: %s", initialHead[:7]) @@ -234,7 +232,7 @@ func TestShadow_UntrackedFilesAcrossMultipleSessions(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/multi-session") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() _ = env.GetHeadHash() // Get initial head but we'll check new head later @@ -360,7 +358,7 @@ func TestShadow_GitignoredFilesExcludedFromSessionState(t *testing.T) { env.WriteFile("notes.txt", "my notes") // Initialize Entire and start session - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() session := env.NewSession() if err := env.SimulateUserPromptSubmit(session.ID); err != nil { @@ -440,7 +438,7 @@ func TestShadow_GitignoredFilesPreservedDuringRewind(t *testing.T) { // Create untracked (not ignored) file before session env.WriteFile("config.yaml", "key: value") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() initialHead := env.GetHeadHash() diff --git a/cmd/entire/cli/integration_test/manual_commit_workflow_test.go b/cmd/entire/cli/integration_test/manual_commit_workflow_test.go index 3a205d7a9..261fd400f 100644 --- a/cmd/entire/cli/integration_test/manual_commit_workflow_test.go +++ b/cmd/entire/cli/integration_test/manual_commit_workflow_test.go @@ -46,7 +46,7 @@ func TestShadow_FullWorkflow(t *testing.T) { env.GitCheckoutNewBranch("feature/auth") // Initialize Entire AFTER branch switch to avoid go-git cleaning untracked files - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() initialHead := env.GetHeadHash() t.Logf("Initial HEAD on feature/auth: %s", initialHead[:7]) @@ -381,7 +381,7 @@ func TestShadow_SessionStateLocation(t *testing.T) { env.GitCheckoutNewBranch("feature/test") // Initialize AFTER branch switch - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() session := env.NewSession() if err := env.SimulateUserPromptSubmit(session.ID); err != nil { @@ -418,7 +418,7 @@ func TestShadow_MultipleConcurrentSessions(t *testing.T) { env.GitCheckoutNewBranch("feature/test") // Initialize AFTER branch switch - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // Start first session session1 := env.NewSession() @@ -493,7 +493,7 @@ func TestShadow_ShadowBranchMigrationOnPull(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() originalHead := env.GetHeadHash() originalShadowBranch := env.GetShadowBranchNameForCommit(originalHead) @@ -587,7 +587,7 @@ func TestShadow_ShadowBranchNaming(t *testing.T) { env.GitCheckoutNewBranch("feature/test") // Initialize AFTER branch switch - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() baseHead := env.GetHeadHash() @@ -638,7 +638,7 @@ func TestShadow_TranscriptCondensation(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // Start session and create checkpoint with transcript session := env.NewSession() @@ -721,7 +721,7 @@ func TestShadow_FullTranscriptContext(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/incremental") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: First session with two prompts") @@ -899,7 +899,7 @@ func TestShadow_RewindAndCondensation(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/rewind-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create first checkpoint with prompt 1") @@ -1049,7 +1049,7 @@ func TestShadow_RewindPreservesUntrackedFilesFromSessionStart(t *testing.T) { env.WriteFile(".claude/settings.json", untrackedContent) // Initialize Entire with manual-commit strategy - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create first checkpoint") @@ -1172,7 +1172,7 @@ func TestShadow_IntermediateCommitsWithoutPrompts(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/intermediate-commits") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Start session and create checkpoint") @@ -1300,7 +1300,7 @@ func TestShadow_FullTranscriptCondensationWithIntermediateCommits(t *testing.T) env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/incremental-intermediate") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Session with two prompts") @@ -1422,7 +1422,7 @@ func TestShadow_RewindPreservesUntrackedFilesWithExistingShadowBranch(t *testing env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/existing-shadow-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create untracked file before session starts") @@ -1562,7 +1562,7 @@ func TestShadow_TrailerRemovalSkipsCondensation(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/trailer-opt-out") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() t.Log("Phase 1: Create session with content") @@ -1681,7 +1681,7 @@ func TestShadow_SessionsBranchCommitTrailers(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/trailer-test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // Start session and create checkpoint session := env.NewSession() diff --git a/cmd/entire/cli/integration_test/mid_session_commit_test.go b/cmd/entire/cli/integration_test/mid_session_commit_test.go index c8147e8d1..4189ade16 100644 --- a/cmd/entire/cli/integration_test/mid_session_commit_test.go +++ b/cmd/entire/cli/integration_test/mid_session_commit_test.go @@ -25,7 +25,7 @@ import ( func TestShadowStrategy_MidSessionCommit_FromTranscript(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() @@ -108,7 +108,7 @@ func TestShadowStrategy_MidSessionCommit_FromTranscript(t *testing.T) { func TestShadowStrategy_MidSessionCommit_NoTrailerWithoutTranscriptPath(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() @@ -139,7 +139,7 @@ func TestShadowStrategy_MidSessionCommit_NoTrailerWithoutTranscriptPath(t *testi func TestShadowStrategy_MidSessionCommit_NoTrailerForUnrelatedFile(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() @@ -188,7 +188,7 @@ func TestShadowStrategy_MidSessionCommit_NoTrailerForUnrelatedFile(t *testing.T) func TestShadowStrategy_AgentCommit_AlwaysGetsTrailer(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() @@ -221,7 +221,7 @@ func TestShadowStrategy_AgentCommit_AlwaysGetsTrailer(t *testing.T) { func TestShadowStrategy_MidSessionCommit_FilesTouchedFallback(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) session := env.NewSession() diff --git a/cmd/entire/cli/integration_test/mid_session_rebase_test.go b/cmd/entire/cli/integration_test/mid_session_rebase_test.go index 325c9db8d..a850fd608 100644 --- a/cmd/entire/cli/integration_test/mid_session_rebase_test.go +++ b/cmd/entire/cli/integration_test/mid_session_rebase_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestShadow_MidSessionRebaseMigration tests that when Claude performs a rebase @@ -49,7 +48,7 @@ func TestShadow_MidSessionRebaseMigration(t *testing.T) { env.GitCheckoutNewBranch("feature/rebase-test") // Initialize Entire after branch creation - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // Create a commit on feature branch env.WriteFile("feature.txt", "feature content") @@ -272,7 +271,7 @@ func TestShadow_CommitThenRebaseMidSession(t *testing.T) { env.GitCheckoutNewBranch("feature/commit-then-rebase") // Initialize Entire - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() initialFeatureHead := env.GetHeadHash() t.Logf("Initial feature HEAD: %s", initialFeatureHead[:7]) diff --git a/cmd/entire/cli/integration_test/old_session_basecommit_test.go b/cmd/entire/cli/integration_test/old_session_basecommit_test.go index 651fbb20e..1012f245d 100644 --- a/cmd/entire/cli/integration_test/old_session_basecommit_test.go +++ b/cmd/entire/cli/integration_test/old_session_basecommit_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/session" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestOldIdleSession_BaseCommitNotUpdated verifies that when an old IDLE session @@ -36,7 +35,7 @@ func TestOldIdleSession_BaseCommitNotUpdated(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test-base-commit") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // ======================================== // Phase 1: Create first session (will become IDLE) @@ -176,7 +175,7 @@ func TestOldEndedSession_BaseCommitNotUpdated(t *testing.T) { env.GitAdd("README.md") env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test-ended-base-commit") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // ======================================== // Phase 1: Create first session and END it diff --git a/cmd/entire/cli/integration_test/opencode_hooks_test.go b/cmd/entire/cli/integration_test/opencode_hooks_test.go index 28edd60c8..eef4bfd3f 100644 --- a/cmd/entire/cli/integration_test/opencode_hooks_test.go +++ b/cmd/entire/cli/integration_test/opencode_hooks_test.go @@ -15,65 +15,61 @@ import ( func TestOpenCodeHookFlow(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - env.InitEntireWithAgent(strategyName, agent.AgentNameOpenCode) - - // Create OpenCode session - session := env.NewOpenCodeSession() - - // 1. session-start - if err := env.SimulateOpenCodeSessionStart(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("session-start error: %v", err) - } - - // 2. turn-start (equivalent to UserPromptSubmit — captures pre-prompt state) - if err := env.SimulateOpenCodeTurnStart(session.ID, session.TranscriptPath, "Add a feature"); err != nil { - t.Fatalf("turn-start error: %v", err) - } - - // 3. Agent makes file changes (AFTER turn-start so they're detected as new) - env.WriteFile("feature.go", "package main\n// new feature") - - // 4. Create transcript with the file change - session.CreateOpenCodeTranscript("Add a feature", []FileChange{ - {Path: "feature.go", Content: "package main\n// new feature"}, - }) - - // 5. turn-end (equivalent to Stop — creates checkpoint) - if err := env.SimulateOpenCodeTurnEnd(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("turn-end error: %v", err) - } - - // 6. Verify checkpoint was created - points := env.GetRewindPoints() - if len(points) == 0 { - t.Fatal("expected at least 1 rewind point after turn-end") - } - - // 7. For manual-commit, user commits manually (triggers condensation). - // For auto-commit, the commit was already made during turn-end. - if strategyName == strategy.StrategyNameManualCommit { - env.GitCommitWithShadowHooks("Add feature", "feature.go") - } - - // 8. session-end - if err := env.SimulateOpenCodeSessionEnd(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("session-end error: %v", err) - } - - // 9. Verify condensation happened (checkpoint on metadata branch) - checkpointID := env.TryGetLatestCheckpointID() - if checkpointID == "" { - t.Fatal("expected checkpoint on metadata branch after commit") - } - - // 10. Verify condensed data - transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName) - _, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) - if !found { - t.Error("condensed transcript should exist on metadata branch") - } + env := NewFeatureBranchEnv(t) + env.InitEntireWithAgent(agent.AgentNameOpenCode) + + // Create OpenCode session + session := env.NewOpenCodeSession() + + // 1. session-start + if err := env.SimulateOpenCodeSessionStart(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("session-start error: %v", err) + } + + // 2. turn-start (equivalent to UserPromptSubmit — captures pre-prompt state) + if err := env.SimulateOpenCodeTurnStart(session.ID, session.TranscriptPath, "Add a feature"); err != nil { + t.Fatalf("turn-start error: %v", err) + } + + // 3. Agent makes file changes (AFTER turn-start so they're detected as new) + env.WriteFile("feature.go", "package main\n// new feature") + + // 4. Create transcript with the file change + session.CreateOpenCodeTranscript("Add a feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// new feature"}, }) + + // 5. turn-end (equivalent to Stop — creates checkpoint) + if err := env.SimulateOpenCodeTurnEnd(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("turn-end error: %v", err) + } + + // 6. Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after turn-end") + } + + // 7. For manual-commit, user commits manually (triggers condensation). + env.GitCommitWithShadowHooks("Add feature", "feature.go") + + // 8. session-end + if err := env.SimulateOpenCodeSessionEnd(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("session-end error: %v", err) + } + + // 9. Verify condensation happened (checkpoint on metadata branch) + checkpointID := env.TryGetLatestCheckpointID() + if checkpointID == "" { + t.Fatal("expected checkpoint on metadata branch after commit") + } + + // 10. Verify condensed data + transcriptPath := SessionFilePath(checkpointID, paths.TranscriptFileName) + _, found := env.ReadFileFromBranch(paths.MetadataBranchName, transcriptPath) + if !found { + t.Error("condensed transcript should exist on metadata branch") + } } // TestOpenCodeAgentStrategyComposition verifies that the OpenCode agent and strategy @@ -81,71 +77,65 @@ func TestOpenCodeHookFlow(t *testing.T) { func TestOpenCodeAgentStrategyComposition(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - env.InitEntireWithAgent(strategyName, agent.AgentNameOpenCode) - - ag, err := agent.Get("opencode") - if err != nil { - t.Fatalf("Get(opencode) error = %v", err) - } - - _, err = strategy.Get(strategyName) - if err != nil { - t.Fatalf("Get(%s) error = %v", strategyName, err) - } - - // Create session and transcript for agent interface testing. - // The transcript references feature.go but the actual file doesn't need - // to exist for ReadSession — it only parses the transcript JSONL. - session := env.NewOpenCodeSession() - transcriptPath := session.CreateOpenCodeTranscript("Add a feature", []FileChange{ - {Path: "feature.go", Content: "package main\n// new feature"}, - }) - - // Read session via agent interface - agentSession, err := ag.ReadSession(&agent.HookInput{ - SessionID: session.ID, - SessionRef: transcriptPath, - }) - if err != nil { - t.Fatalf("ReadSession() error = %v", err) - } - - // Verify agent computed modified files - if len(agentSession.ModifiedFiles) == 0 { - t.Error("agent.ReadSession() should compute ModifiedFiles") - } - - // Simulate session flow: session-start → turn-start → file changes → turn-end - if err := env.SimulateOpenCodeSessionStart(session.ID, transcriptPath); err != nil { - t.Fatalf("session-start error = %v", err) - } - if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Add a feature"); err != nil { - t.Fatalf("turn-start error = %v", err) - } - - // Create the actual file AFTER turn-start so the strategy detects it as new - env.WriteFile("feature.go", "package main\n// new feature") - - if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { - t.Fatalf("turn-end error = %v", err) - } - - // Verify checkpoint was created - points := env.GetRewindPoints() - if len(points) == 0 { - t.Fatal("expected at least 1 rewind point after turn-end") - } + env := NewFeatureBranchEnv(t) + env.InitEntireWithAgent(agent.AgentNameOpenCode) + + ag, err := agent.Get("opencode") + if err != nil { + t.Fatalf("Get(opencode) error = %v", err) + } + + // Create session and transcript for agent interface testing. + // The transcript references feature.go but the actual file doesn't need + // to exist for ReadSession — it only parses the transcript JSONL. + session := env.NewOpenCodeSession() + transcriptPath := session.CreateOpenCodeTranscript("Add a feature", []FileChange{ + {Path: "feature.go", Content: "package main\n// new feature"}, + }) + + // Read session via agent interface + agentSession, err := ag.ReadSession(&agent.HookInput{ + SessionID: session.ID, + SessionRef: transcriptPath, }) + if err != nil { + t.Fatalf("ReadSession() error = %v", err) + } + + // Verify agent computed modified files + if len(agentSession.ModifiedFiles) == 0 { + t.Error("agent.ReadSession() should compute ModifiedFiles") + } + + // Simulate session flow: session-start → turn-start → file changes → turn-end + if err := env.SimulateOpenCodeSessionStart(session.ID, transcriptPath); err != nil { + t.Fatalf("session-start error = %v", err) + } + if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Add a feature"); err != nil { + t.Fatalf("turn-start error = %v", err) + } + + // Create the actual file AFTER turn-start so the strategy detects it as new + env.WriteFile("feature.go", "package main\n// new feature") + + if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("turn-end error = %v", err) + } + + // Verify checkpoint was created + points := env.GetRewindPoints() + if len(points) == 0 { + t.Fatal("expected at least 1 rewind point after turn-end") + } } // TestOpenCodeRewind verifies that rewind works with OpenCode checkpoints. func TestOpenCodeRewind(t *testing.T) { t.Parallel() + env := NewFeatureBranchEnv(t) // Test with manual-commit strategy as it has full file restoration on rewind - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameOpenCode) + env.InitEntireWithAgent(agent.AgentNameOpenCode) // First session session := env.NewOpenCodeSession() @@ -219,8 +209,8 @@ func TestOpenCodeRewind(t *testing.T) { func TestOpenCodeMultiTurnCondensation(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameOpenCode) + env := NewFeatureBranchEnv(t) + env.InitEntireWithAgent(agent.AgentNameOpenCode) session := env.NewOpenCodeSession() transcriptPath := session.TranscriptPath @@ -285,8 +275,8 @@ func TestOpenCodeMultiTurnCondensation(t *testing.T) { func TestOpenCodeMidTurnCommit(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - env.InitEntireWithAgent(strategy.StrategyNameManualCommit, agent.AgentNameOpenCode) + env := NewFeatureBranchEnv(t) + env.InitEntireWithAgent(agent.AgentNameOpenCode) session := env.NewOpenCodeSession() @@ -353,78 +343,71 @@ func TestOpenCodeMidTurnCommit(t *testing.T) { func TestOpenCodeResumedSessionAfterCommit(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - env.InitEntireWithAgent(strategyName, agent.AgentNameOpenCode) - - session := env.NewOpenCodeSession() - transcriptPath := session.TranscriptPath - - // === Turn 1: Create a new file === - if err := env.SimulateOpenCodeSessionStart(session.ID, transcriptPath); err != nil { - t.Fatalf("session-start error: %v", err) - } - if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Create app.go"); err != nil { - t.Fatalf("turn-start 1 error: %v", err) - } - - env.WriteFile("app.go", "package main\nfunc main() {}") - session.CreateOpenCodeTranscript("Create app.go", []FileChange{ - {Path: "app.go", Content: "package main\nfunc main() {}"}, - }) - - if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { - t.Fatalf("turn-end 1 error: %v", err) - } - - points1 := env.GetRewindPoints() - if len(points1) == 0 { - t.Fatal("expected rewind point after turn 1") - } - - // === User commits (triggers condensation) === - // For auto-commit, turn-end already committed the file. - // For manual-commit, user commits manually. - if strategyName == strategy.StrategyNameManualCommit { - env.GitCommitWithShadowHooks("Create app", "app.go") - } - - // Verify condensation happened - checkpointID := env.TryGetLatestCheckpointID() - if checkpointID == "" { - t.Fatal("expected checkpoint on metadata branch after commit") - } - - // === Turn 2 (resumed): Modify the now-tracked file === - if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Add color output"); err != nil { - t.Fatalf("turn-start 2 error: %v", err) - } - - env.WriteFile("app.go", "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"hello\") }") - session.CreateOpenCodeTranscript("Add color output", []FileChange{ - {Path: "app.go", Content: "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"hello\") }"}, - }) - - if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { - t.Fatalf("turn-end 2 error: %v", err) - } - - // === Verify: a new checkpoint was created for turn 2 === - points2 := env.GetRewindPoints() - if len(points2) == 0 { - t.Fatal("expected rewind point after turn 2 (resumed session), got none") - } - - // For manual-commit: commit turn 2 and verify second condensation - if strategyName == strategy.StrategyNameManualCommit { - env.GitCommitWithShadowHooks("Add color output", "app.go") - - checkpointID2 := env.TryGetLatestCheckpointID() - if checkpointID2 == "" { - t.Fatal("expected second checkpoint on metadata branch after turn 2 commit") - } - if checkpointID2 == checkpointID { - t.Error("second checkpoint ID should differ from first") - } - } + env := NewFeatureBranchEnv(t) + env.InitEntireWithAgent(agent.AgentNameOpenCode) + + session := env.NewOpenCodeSession() + transcriptPath := session.TranscriptPath + + // === Turn 1: Create a new file === + if err := env.SimulateOpenCodeSessionStart(session.ID, transcriptPath); err != nil { + t.Fatalf("session-start error: %v", err) + } + if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Create app.go"); err != nil { + t.Fatalf("turn-start 1 error: %v", err) + } + + env.WriteFile("app.go", "package main\nfunc main() {}") + session.CreateOpenCodeTranscript("Create app.go", []FileChange{ + {Path: "app.go", Content: "package main\nfunc main() {}"}, }) + + if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("turn-end 1 error: %v", err) + } + + points1 := env.GetRewindPoints() + if len(points1) == 0 { + t.Fatal("expected rewind point after turn 1") + } + + // === User commits (triggers condensation) === + env.GitCommitWithShadowHooks("Create app", "app.go") + + // Verify condensation happened + checkpointID := env.TryGetLatestCheckpointID() + if checkpointID == "" { + t.Fatal("expected checkpoint on metadata branch after commit") + } + + // === Turn 2 (resumed): Modify the now-tracked file === + if err := env.SimulateOpenCodeTurnStart(session.ID, transcriptPath, "Add color output"); err != nil { + t.Fatalf("turn-start 2 error: %v", err) + } + + env.WriteFile("app.go", "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"hello\") }") + session.CreateOpenCodeTranscript("Add color output", []FileChange{ + {Path: "app.go", Content: "package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"hello\") }"}, + }) + + if err := env.SimulateOpenCodeTurnEnd(session.ID, transcriptPath); err != nil { + t.Fatalf("turn-end 2 error: %v", err) + } + + // === Verify: a new checkpoint was created for turn 2 === + points2 := env.GetRewindPoints() + if len(points2) == 0 { + t.Fatal("expected rewind point after turn 2 (resumed session), got none") + } + + // For manual-commit: commit turn 2 and verify second condensation + env.GitCommitWithShadowHooks("Add color output", "app.go") + + checkpointID2 := env.TryGetLatestCheckpointID() + if checkpointID2 == "" { + t.Fatal("expected second checkpoint on metadata branch after turn 2 commit") + } + if checkpointID2 == checkpointID { + t.Error("second checkpoint ID should differ from first") + } } diff --git a/cmd/entire/cli/integration_test/phase_transitions_test.go b/cmd/entire/cli/integration_test/phase_transitions_test.go index 6f3acbbd4..d4cfcb2c6 100644 --- a/cmd/entire/cli/integration_test/phase_transitions_test.go +++ b/cmd/entire/cli/integration_test/phase_transitions_test.go @@ -7,7 +7,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) // TestShadow_CommitBeforeStop tests the "commit while agent is still working" flow. @@ -23,7 +22,7 @@ import ( func TestShadow_CommitBeforeStop(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // ======================================== // Phase 1: Start session and create initial checkpoint @@ -203,7 +202,7 @@ func TestShadow_CommitBeforeStop(t *testing.T) { func TestShadow_AmendPreservesTrailer(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // ======================================== // Phase 1: Full workflow - create checkpoint and commit diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index 6459a02e1..bcdd51a30 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -12,8 +12,6 @@ import ( "testing" "time" - "github.com/entireio/cli/cmd/entire/cli/strategy" - "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" ) @@ -24,7 +22,7 @@ const masterBranch = "master" // that has a commit with an Entire-Checkpoint trailer. func TestResume_SwitchBranchWithSession(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session on the feature branch session := env.NewSession() @@ -89,8 +87,7 @@ func TestResume_SwitchBranchWithSession(t *testing.T) { // TestResume_AlreadyOnBranch tests that resume works when already on the target branch. func TestResume_AlreadyOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - + env := NewFeatureBranchEnv(t) // Create a session on the feature branch session := env.NewSession() if err := env.SimulateUserPromptSubmit(session.ID); err != nil { @@ -129,8 +126,7 @@ func TestResume_AlreadyOnBranch(t *testing.T) { // any Entire-Checkpoint trailer in their history gracefully. func TestResume_NoCheckpointOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) - + env := NewFeatureBranchEnv(t) // Create a branch directly from master (which has no checkpoints) // Switch to master first env.GitCheckoutBranch(masterBranch) @@ -168,7 +164,7 @@ func TestResume_NoCheckpointOnBranch(t *testing.T) { // TestResume_BranchDoesNotExist tests that resume returns an error for non-existent branches. func TestResume_BranchDoesNotExist(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Try to resume a non-existent branch output, err := env.RunResume("nonexistent-branch") @@ -187,7 +183,7 @@ func TestResume_BranchDoesNotExist(t *testing.T) { // TestResume_UncommittedChanges tests that resume fails when there are uncommitted changes. func TestResume_UncommittedChanges(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create another branch env.GitCheckoutNewBranch("feature/target") @@ -219,7 +215,7 @@ func TestResume_UncommittedChanges(t *testing.T) { // with the checkpoint's version. This ensures consistency when resuming from a different device. func TestResume_SessionLogAlreadyExists(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session session := env.NewSession() @@ -286,7 +282,7 @@ func TestResume_SessionLogAlreadyExists(t *testing.T) { // ensuring it uses the session from the last commit. func TestResume_MultipleSessionsOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create first session session1 := env.NewSession() @@ -353,7 +349,7 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { // This can happen if the metadata branch was corrupted or reset. func TestResume_CheckpointWithoutMetadata(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // First create a real session so the entire/checkpoints/v1 branch exists session := env.NewSession() @@ -412,7 +408,7 @@ func TestResume_CheckpointWithoutMetadata(t *testing.T) { // Since the only "newer" commits are merge commits, no confirmation should be required. func TestResume_AfterMergingMain(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session on the feature branch session := env.NewSession() @@ -591,7 +587,7 @@ func (env *TestEnv) GitCheckoutBranch(branchName string) { // and does NOT overwrite the local log. This ensures safe behavior in CI environments. func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session with a specific timestamp session := env.NewSession() @@ -652,7 +648,7 @@ func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { // and overwrites the local log. func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session with a specific timestamp session := env.NewSession() @@ -714,7 +710,7 @@ func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { // confirms the overwrite prompt interactively, the local log is overwritten. func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session with a specific timestamp session := env.NewSession() @@ -781,7 +777,7 @@ func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { // declines the overwrite prompt interactively, the local log is preserved. func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session with a specific timestamp session := env.NewSession() @@ -849,7 +845,7 @@ func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { // than local log, resume proceeds without requiring --force. func TestResume_CheckpointNewerTimestamp(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session session := env.NewSession() @@ -911,7 +907,7 @@ func TestResume_CheckpointNewerTimestamp(t *testing.T) { // where one session has a newer local log (conflict) and another doesn't (no conflict). func TestResume_MultiSessionMixedTimestamps(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create first session session1 := env.NewSession() @@ -1018,7 +1014,7 @@ func TestResume_MultiSessionMixedTimestamps(t *testing.T) { // resume proceeds without requiring --force (treated as new). func TestResume_LocalLogNoTimestamp(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Create a session session := env.NewSession() diff --git a/cmd/entire/cli/integration_test/rewind_test.go b/cmd/entire/cli/integration_test/rewind_test.go index e6f9034ba..ac871252c 100644 --- a/cmd/entire/cli/integration_test/rewind_test.go +++ b/cmd/entire/cli/integration_test/rewind_test.go @@ -19,93 +19,92 @@ import ( // one continuous Claude session with multiple prompts. func TestRewind_FullWorkflow(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Use same session for all checkpoints (works for all strategies) - session := env.NewSession() + env := NewFeatureBranchEnv(t) + // Use same session for all checkpoints (works for all strategies) + session := env.NewSession() - // Checkpoint 1: Add ruby script - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Checkpoint 1: Add ruby script + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - rubyV1 := "puts rand(100)" - env.WriteFile("random.rb", rubyV1) + rubyV1 := "puts rand(100)" + env.WriteFile("random.rb", rubyV1) - session.CreateTranscript( - "Add a ruby script that returns a random number", - []FileChange{{Path: "random.rb", Content: rubyV1}}, - ) - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop checkpoint1 failed: %v", err) - } + session.CreateTranscript( + "Add a ruby script that returns a random number", + []FileChange{{Path: "random.rb", Content: rubyV1}}, + ) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop checkpoint1 failed: %v", err) + } - // Checkpoint 2: Change to red - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Checkpoint 2: Change to red + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - rubyV2 := `puts "\e[31m#{rand(100)}\e[0m"` - env.WriteFile("random.rb", rubyV2) + rubyV2 := `puts "\e[31m#{rand(100)}\e[0m"` + env.WriteFile("random.rb", rubyV2) - session.CreateTranscript( - "Change the output to use red color", - []FileChange{{Path: "random.rb", Content: rubyV2}}, - ) - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop checkpoint2 failed: %v", err) - } + session.CreateTranscript( + "Change the output to use red color", + []FileChange{{Path: "random.rb", Content: rubyV2}}, + ) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop checkpoint2 failed: %v", err) + } - // Checkpoint 3: Change to green - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Checkpoint 3: Change to green + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - rubyV3 := `puts "\e[32m#{rand(100)}\e[0m"` - env.WriteFile("random.rb", rubyV3) + rubyV3 := `puts "\e[32m#{rand(100)}\e[0m"` + env.WriteFile("random.rb", rubyV3) - session.CreateTranscript( - "Change the output to use green color", - []FileChange{{Path: "random.rb", Content: rubyV3}}, - ) - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop checkpoint3 failed: %v", err) - } + session.CreateTranscript( + "Change the output to use green color", + []FileChange{{Path: "random.rb", Content: rubyV3}}, + ) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop checkpoint3 failed: %v", err) + } - // Verify current state is green version - content := env.ReadFile("random.rb") - if content != rubyV3 { - t.Errorf("before rewind: got %q, want %q", content, rubyV3) - } + // Verify current state is green version + content := env.ReadFile("random.rb") + if content != rubyV3 { + t.Errorf("before rewind: got %q, want %q", content, rubyV3) + } - // Get rewind points - should have 3 - points := env.GetRewindPoints() - if len(points) != 3 { - t.Fatalf("expected 3 rewind points, got %d", len(points)) - } + // Get rewind points - should have 3 + points := env.GetRewindPoints() + if len(points) != 3 { + t.Fatalf("expected 3 rewind points, got %d", len(points)) + } - // Points are ordered newest first, so checkpoint 1 is last - checkpoint1Point := points[2] - if checkpoint1Point.Message == "" { - t.Error("rewind point should have a message") - } + // Points are ordered newest first, so checkpoint 1 is last + checkpoint1Point := points[2] + if checkpoint1Point.Message == "" { + t.Error("rewind point should have a message") + } - // Execute rewind to checkpoint 1 - if err := env.Rewind(checkpoint1Point.ID); err != nil { - t.Fatalf("Rewind failed: %v", err) - } + // Execute rewind to checkpoint 1 + if err := env.Rewind(checkpoint1Point.ID); err != nil { + t.Fatalf("Rewind failed: %v", err) + } - // CORE ASSERTION: File contents restored - content = env.ReadFile("random.rb") - if content != rubyV1 { - t.Errorf("after rewind: got %q, want %q", content, rubyV1) - } + // CORE ASSERTION: File contents restored + content = env.ReadFile("random.rb") + if content != rubyV1 { + t.Errorf("after rewind: got %q, want %q", content, rubyV1) + } - // CORE ASSERTION: Transcript available for resumption - transcriptPath := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl") - if _, err := os.Stat(transcriptPath); os.IsNotExist(err) { - t.Errorf("transcript should be copied for claude -r, expected at %s", transcriptPath) - } - }) + // CORE ASSERTION: Transcript available for resumption + transcriptPath := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl") + if _, err := os.Stat(transcriptPath); os.IsNotExist(err) { + t.Errorf("transcript should be copied for claude -r, expected at %s", transcriptPath) + } } // TestTaskCheckpoint_RewindWorkflow tests rewind to task checkpoint (subagent completion). @@ -116,179 +115,178 @@ func TestRewind_FullWorkflow(t *testing.T) { // - Transcript is available for session resumption func TestTaskCheckpoint_RewindWorkflow(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, _ string) { - // Start a session - session := env.NewSession() - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + env := NewFeatureBranchEnv(t) + // Start a session + session := env.NewSession() + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - // Build the transcript progressively - session.TranscriptBuilder.AddUserMessage("Create some files using subagents") - session.TranscriptBuilder.AddAssistantMessage("I'll use the Task tool to help.") + // Build the transcript progressively + session.TranscriptBuilder.AddUserMessage("Create some files using subagents") + session.TranscriptBuilder.AddAssistantMessage("I'll use the Task tool to help.") - // === Task 1: Create first file === - task1ID := "toolu_task1_abc" - task1AgentID := "agent_task1_xyz" + // === Task 1: Create first file === + task1ID := "toolu_task1_abc" + task1AgentID := "agent_task1_xyz" - // Add Task tool use to transcript - session.TranscriptBuilder.AddTaskToolUse(task1ID, "Create task1.txt") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } + // Add Task tool use to transcript + session.TranscriptBuilder.AddTaskToolUse(task1ID, "Create task1.txt") + if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } - // Pre-task hook (before subagent runs) - if err := env.SimulatePreTask(session.ID, session.TranscriptPath, task1ID); err != nil { - t.Fatalf("SimulatePreTask task1 failed: %v", err) - } + // Pre-task hook (before subagent runs) + if err := env.SimulatePreTask(session.ID, session.TranscriptPath, task1ID); err != nil { + t.Fatalf("SimulatePreTask task1 failed: %v", err) + } - // Simulate subagent creating the file - task1Content := "Created by task 1" - env.WriteFile("task1.txt", task1Content) + // Simulate subagent creating the file + task1Content := "Created by task 1" + env.WriteFile("task1.txt", task1Content) - // Create subagent transcript - subagent1 := NewTranscriptBuilder() - subagent1.AddUserMessage("Create task1.txt") - toolID1 := subagent1.AddToolUse("mcp__acp__Write", "task1.txt", task1Content) - subagent1.AddToolResult(toolID1) - subagent1.AddAssistantMessage("Created task1.txt") + // Create subagent transcript + subagent1 := NewTranscriptBuilder() + subagent1.AddUserMessage("Create task1.txt") + toolID1 := subagent1.AddToolUse("mcp__acp__Write", "task1.txt", task1Content) + subagent1.AddToolResult(toolID1) + subagent1.AddAssistantMessage("Created task1.txt") - subagent1Path := filepath.Join(filepath.Dir(session.TranscriptPath), "agent-"+task1AgentID+".jsonl") - if err := subagent1.WriteToFile(subagent1Path); err != nil { - t.Fatalf("failed to write subagent1 transcript: %v", err) - } + subagent1Path := filepath.Join(filepath.Dir(session.TranscriptPath), "agent-"+task1AgentID+".jsonl") + if err := subagent1.WriteToFile(subagent1Path); err != nil { + t.Fatalf("failed to write subagent1 transcript: %v", err) + } - // Add task result to main transcript - session.TranscriptBuilder.AddTaskToolResult(task1ID, task1AgentID) - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } + // Add task result to main transcript + session.TranscriptBuilder.AddTaskToolResult(task1ID, task1AgentID) + if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } - // Post-task hook (creates checkpoint) - if err := env.SimulatePostTask(PostTaskInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: task1ID, - AgentID: task1AgentID, - }); err != nil { - t.Fatalf("SimulatePostTask task1 failed: %v", err) - } + // Post-task hook (creates checkpoint) + if err := env.SimulatePostTask(PostTaskInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: task1ID, + AgentID: task1AgentID, + }); err != nil { + t.Fatalf("SimulatePostTask task1 failed: %v", err) + } - // === Task 2: Create second file === - task2ID := "toolu_task2_def" - task2AgentID := "agent_task2_uvw" + // === Task 2: Create second file === + task2ID := "toolu_task2_def" + task2AgentID := "agent_task2_uvw" - session.TranscriptBuilder.AddAssistantMessage("Now creating another file.") - session.TranscriptBuilder.AddTaskToolUse(task2ID, "Create task2.txt") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } + session.TranscriptBuilder.AddAssistantMessage("Now creating another file.") + session.TranscriptBuilder.AddTaskToolUse(task2ID, "Create task2.txt") + if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } - if err := env.SimulatePreTask(session.ID, session.TranscriptPath, task2ID); err != nil { - t.Fatalf("SimulatePreTask task2 failed: %v", err) - } + if err := env.SimulatePreTask(session.ID, session.TranscriptPath, task2ID); err != nil { + t.Fatalf("SimulatePreTask task2 failed: %v", err) + } - // Simulate subagent creating the second file - task2Content := "Created by task 2" - env.WriteFile("task2.txt", task2Content) + // Simulate subagent creating the second file + task2Content := "Created by task 2" + env.WriteFile("task2.txt", task2Content) - // Create subagent transcript - subagent2 := NewTranscriptBuilder() - subagent2.AddUserMessage("Create task2.txt") - toolID2 := subagent2.AddToolUse("mcp__acp__Write", "task2.txt", task2Content) - subagent2.AddToolResult(toolID2) - subagent2.AddAssistantMessage("Created task2.txt") + // Create subagent transcript + subagent2 := NewTranscriptBuilder() + subagent2.AddUserMessage("Create task2.txt") + toolID2 := subagent2.AddToolUse("mcp__acp__Write", "task2.txt", task2Content) + subagent2.AddToolResult(toolID2) + subagent2.AddAssistantMessage("Created task2.txt") - subagent2Path := filepath.Join(filepath.Dir(session.TranscriptPath), "agent-"+task2AgentID+".jsonl") - if err := subagent2.WriteToFile(subagent2Path); err != nil { - t.Fatalf("failed to write subagent2 transcript: %v", err) - } + subagent2Path := filepath.Join(filepath.Dir(session.TranscriptPath), "agent-"+task2AgentID+".jsonl") + if err := subagent2.WriteToFile(subagent2Path); err != nil { + t.Fatalf("failed to write subagent2 transcript: %v", err) + } - // Add task result to main transcript - session.TranscriptBuilder.AddTaskToolResult(task2ID, task2AgentID) - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } + // Add task result to main transcript + session.TranscriptBuilder.AddTaskToolResult(task2ID, task2AgentID) + if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } - // Post-task hook (creates checkpoint) - if err := env.SimulatePostTask(PostTaskInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: task2ID, - AgentID: task2AgentID, - }); err != nil { - t.Fatalf("SimulatePostTask task2 failed: %v", err) - } + // Post-task hook (creates checkpoint) + if err := env.SimulatePostTask(PostTaskInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: task2ID, + AgentID: task2AgentID, + }); err != nil { + t.Fatalf("SimulatePostTask task2 failed: %v", err) + } - // End session - session.TranscriptBuilder.AddAssistantMessage("All tasks complete!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } + // End session + session.TranscriptBuilder.AddAssistantMessage("All tasks complete!") + if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { + t.Fatalf("failed to write transcript: %v", err) + } - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } - // Verify both files exist - if !env.FileExists("task1.txt") { - t.Error("task1.txt should exist after session") - } - if !env.FileExists("task2.txt") { - t.Error("task2.txt should exist after session") - } + // Verify both files exist + if !env.FileExists("task1.txt") { + t.Error("task1.txt should exist after session") + } + if !env.FileExists("task2.txt") { + t.Error("task2.txt should exist after session") + } - // Get rewind points - points := env.GetRewindPoints() + // Get rewind points + points := env.GetRewindPoints() - // Filter to task checkpoints only - taskPoints := filterTaskCheckpoints(points) - if len(taskPoints) < 2 { - t.Fatalf("expected at least 2 task checkpoints, got %d (total points: %d)", len(taskPoints), len(points)) - } + // Filter to task checkpoints only + taskPoints := filterTaskCheckpoints(points) + if len(taskPoints) < 2 { + t.Fatalf("expected at least 2 task checkpoints, got %d (total points: %d)", len(taskPoints), len(points)) + } - // Find task 1 checkpoint - var task1Point *RewindPoint - for i := range taskPoints { - if taskPoints[i].ToolUseID == task1ID { - task1Point = &taskPoints[i] - break - } - } - if task1Point == nil { - t.Fatalf("could not find task 1 checkpoint (looking for ToolUseID=%s)", task1ID) - } - if !task1Point.IsTaskCheckpoint { - t.Error("task1 point should have IsTaskCheckpoint=true") + // Find task 1 checkpoint + var task1Point *RewindPoint + for i := range taskPoints { + if taskPoints[i].ToolUseID == task1ID { + task1Point = &taskPoints[i] + break } + } + if task1Point == nil { + t.Fatalf("could not find task 1 checkpoint (looking for ToolUseID=%s)", task1ID) + } + if !task1Point.IsTaskCheckpoint { + t.Error("task1 point should have IsTaskCheckpoint=true") + } - // Rewind to task 1 checkpoint - if err := env.Rewind(task1Point.ID); err != nil { - t.Fatalf("Rewind to task1 failed: %v", err) - } + // Rewind to task 1 checkpoint + if err := env.Rewind(task1Point.ID); err != nil { + t.Fatalf("Rewind to task1 failed: %v", err) + } - // CORE ASSERTION: task1.txt should exist with correct content - if !env.FileExists("task1.txt") { - t.Error("task1.txt should exist after rewind to task 1") - } else { - content := env.ReadFile("task1.txt") - if content != task1Content { - t.Errorf("task1.txt content: got %q, want %q", content, task1Content) - } + // CORE ASSERTION: task1.txt should exist with correct content + if !env.FileExists("task1.txt") { + t.Error("task1.txt should exist after rewind to task 1") + } else { + content := env.ReadFile("task1.txt") + if content != task1Content { + t.Errorf("task1.txt content: got %q, want %q", content, task1Content) } + } - // CORE ASSERTION: Transcript available for resumption (uses model session ID for Claude compatibility) - transcriptPath := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl") - if _, err := os.Stat(transcriptPath); os.IsNotExist(err) { - t.Errorf("transcript should be copied for claude -r, expected at %s", transcriptPath) - } else { - // Verify transcript contains task1 but ideally not task2 completion - transcriptContent := env.ReadFileAbsolute(transcriptPath) - if !strings.Contains(transcriptContent, task1ID) { - t.Error("restored transcript should contain task1 tool use") - } + // CORE ASSERTION: Transcript available for resumption (uses model session ID for Claude compatibility) + transcriptPath := filepath.Join(env.ClaudeProjectDir, session.ID+".jsonl") + if _, err := os.Stat(transcriptPath); os.IsNotExist(err) { + t.Errorf("transcript should be copied for claude -r, expected at %s", transcriptPath) + } else { + // Verify transcript contains task1 but ideally not task2 completion + transcriptContent := env.ReadFileAbsolute(transcriptPath) + if !strings.Contains(transcriptContent, task1ID) { + t.Error("restored transcript should contain task1 tool use") } - }) + } } // filterTaskCheckpoints returns only task checkpoints from the rewind points. @@ -306,134 +304,131 @@ func filterTaskCheckpoints(points []RewindPoint) []RewindPoint { // All strategies use a single session with multiple checkpoints. func TestRewind_MultipleNewFiles(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // .gitignore for .entire/ is set up by NewRepoWithCommit() + env := NewFeatureBranchEnv(t) + // .gitignore for .entire/ is set up by NewRepoWithCommit() - // Use same session for all checkpoints (works for all strategies) - session := env.NewSession() + // Use same session for all checkpoints (works for all strategies) + session := env.NewSession() - // Checkpoint 1: creating multiple files in different directories - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Checkpoint 1: creating multiple files in different directories + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - files := []FileChange{ - {Path: "src/main.go", Content: "package main"}, - {Path: "src/util.go", Content: "package main\n\nfunc helper() {}"}, - {Path: "tests/main_test.go", Content: "package main_test"}, - {Path: "config.yaml", Content: "key: value"}, - } + files := []FileChange{ + {Path: "src/main.go", Content: "package main"}, + {Path: "src/util.go", Content: "package main\n\nfunc helper() {}"}, + {Path: "tests/main_test.go", Content: "package main_test"}, + {Path: "config.yaml", Content: "key: value"}, + } - for _, f := range files { - env.WriteFile(f.Path, f.Content) - } + for _, f := range files { + env.WriteFile(f.Path, f.Content) + } - session.CreateTranscript("Create project structure", files) - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } + session.CreateTranscript("Create project structure", files) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } - // Verify all files exist - for _, f := range files { - if !env.FileExists(f.Path) { - t.Errorf("file %s should exist after session", f.Path) - } + // Verify all files exist + for _, f := range files { + if !env.FileExists(f.Path) { + t.Errorf("file %s should exist after session", f.Path) } + } - // Checkpoint 2: Modify one file - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Checkpoint 2: Modify one file + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - modifiedContent := "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}" - env.WriteFile("src/main.go", modifiedContent) - session.CreateTranscript("Add main function", - []FileChange{{Path: "src/main.go", Content: modifiedContent}}) - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } + modifiedContent := "package main\n\nfunc main() {\n\tprintln(\"hello\")\n}" + env.WriteFile("src/main.go", modifiedContent) + session.CreateTranscript("Add main function", + []FileChange{{Path: "src/main.go", Content: modifiedContent}}) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop failed: %v", err) + } - // Verify modification - if content := env.ReadFile("src/main.go"); content != modifiedContent { - t.Errorf("got %q, want %q", content, modifiedContent) - } + // Verify modification + if content := env.ReadFile("src/main.go"); content != modifiedContent { + t.Errorf("got %q, want %q", content, modifiedContent) + } - // Rewind to checkpoint 1 - points := env.GetRewindPoints() - if len(points) < 2 { - t.Fatalf("expected at least 2 rewind points, got %d", len(points)) - } + // Rewind to checkpoint 1 + points := env.GetRewindPoints() + if len(points) < 2 { + t.Fatalf("expected at least 2 rewind points, got %d", len(points)) + } - // Checkpoint 1 is the older one (last in the list) - if err := env.Rewind(points[len(points)-1].ID); err != nil { - t.Fatalf("Rewind failed: %v", err) - } + // Checkpoint 1 is the older one (last in the list) + if err := env.Rewind(points[len(points)-1].ID); err != nil { + t.Fatalf("Rewind failed: %v", err) + } - // Verify original content restored - if content := env.ReadFile("src/main.go"); content != "package main" { - t.Errorf("after rewind: got %q, want %q", content, "package main") - } - }) + // Verify original content restored + if content := env.ReadFile("src/main.go"); content != "package main" { + t.Errorf("after rewind: got %q, want %q", content, "package main") + } } // TestRewind_MultipleConsecutive tests multiple consecutive rewinds. // All strategies use a single session with multiple checkpoints. func TestRewind_MultipleConsecutive(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // .gitignore for .entire/ is set up by NewRepoWithCommit() - - // Use same session for all checkpoints (works for all strategies) - session := env.NewSession() - - // Create 3 checkpoints with different versions - versions := []string{"version 1", "version 2", "version 3"} - for _, version := range versions { - if err := env.SimulateUserPromptSubmit(session.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - env.WriteFile("file.txt", version) - session.CreateTranscript( - "Update file", - []FileChange{{Path: "file.txt", Content: version}}, - ) - if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { - t.Fatalf("SimulateStop checkpoint %s failed: %v", version, err) - } - } + env := NewFeatureBranchEnv(t) + // .gitignore for .entire/ is set up by NewRepoWithCommit() - // Verify we're at version 3 - if content := env.ReadFile("file.txt"); content != "version 3" { - t.Errorf("expected version 3, got %q", content) - } + // Use same session for all checkpoints (works for all strategies) + session := env.NewSession() - points := env.GetRewindPoints() - if len(points) != 3 { - t.Fatalf("expected 3 rewind points, got %d", len(points)) + // Create 3 checkpoints with different versions + versions := []string{"version 1", "version 2", "version 3"} + for _, version := range versions { + if err := env.SimulateUserPromptSubmit(session.ID); err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) } - // Rewind to version 2 (second newest = index 1) - if err := env.Rewind(points[1].ID); err != nil { - t.Fatalf("Rewind to v2 failed: %v", err) - } - if content := env.ReadFile("file.txt"); content != "version 2" { - t.Errorf("after rewind to v2: got %q, want %q", content, "version 2") + env.WriteFile("file.txt", version) + session.CreateTranscript( + "Update file", + []FileChange{{Path: "file.txt", Content: version}}, + ) + if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil { + t.Fatalf("SimulateStop checkpoint %s failed: %v", version, err) } + } - // Get fresh points after rewind - points = env.GetRewindPoints() - if len(points) == 0 { - t.Fatalf("No rewind points found after first rewind") - } + // Verify we're at version 3 + if content := env.ReadFile("file.txt"); content != "version 3" { + t.Errorf("expected version 3, got %q", content) + } - // Rewind to version 1 (oldest) - if err := env.Rewind(points[len(points)-1].ID); err != nil { - t.Fatalf("Rewind to v1 failed: %v", err) - } - if content := env.ReadFile("file.txt"); content != "version 1" { - t.Errorf("after rewind to v1: got %q, want %q", content, "version 1") - } - }) + points := env.GetRewindPoints() + if len(points) != 3 { + t.Fatalf("expected 3 rewind points, got %d", len(points)) + } + + // Rewind to version 2 (second newest = index 1) + if err := env.Rewind(points[1].ID); err != nil { + t.Fatalf("Rewind to v2 failed: %v", err) + } + if content := env.ReadFile("file.txt"); content != "version 2" { + t.Errorf("after rewind to v2: got %q, want %q", content, "version 2") + } + // Get fresh points after rewind + points = env.GetRewindPoints() + if len(points) == 0 { + t.Fatalf("No rewind points found after first rewind") + } + + // Rewind to version 1 (oldest) + if err := env.Rewind(points[len(points)-1].ID); err != nil { + t.Fatalf("Rewind to v1 failed: %v", err) + } + if content := env.ReadFile("file.txt"); content != "version 1" { + t.Errorf("after rewind to v1: got %q, want %q", content, "version 1") + } } diff --git a/cmd/entire/cli/integration_test/session_conflict_test.go b/cmd/entire/cli/integration_test/session_conflict_test.go index 79ce97159..cd30c32df 100644 --- a/cmd/entire/cli/integration_test/session_conflict_test.go +++ b/cmd/entire/cli/integration_test/session_conflict_test.go @@ -10,8 +10,6 @@ import ( "testing" "time" - "github.com/entireio/cli/cmd/entire/cli/strategy" - "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" @@ -31,7 +29,7 @@ func TestSessionIDConflict_OrphanedBranchIsReset(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() baseHead := env.GetHeadHash() shadowBranch := env.GetShadowBranchNameForCommit(baseHead) @@ -107,7 +105,7 @@ func TestSessionIDConflict_NoConflictWithSameSession(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // Create a session and checkpoint session := env.NewSession() @@ -143,7 +141,7 @@ func TestSessionIDConflict_NoShadowBranch(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() baseHead := env.GetHeadHash() shadowBranch := env.GetShadowBranchNameForCommit(baseHead) @@ -175,7 +173,7 @@ func TestSessionIDConflict_ManuallyCreatedOrphanedBranch(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() baseHead := env.GetHeadHash() shadowBranch := env.GetShadowBranchNameForCommit(baseHead) @@ -286,7 +284,7 @@ func TestSessionIDConflict_ShadowBranchWithoutTrailer(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() baseHead := env.GetHeadHash() shadowBranch := env.GetShadowBranchNameForCommit(baseHead) @@ -321,7 +319,7 @@ func TestSessionStart_InformationalMessage(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // Create first session and save a checkpoint (so StepCount > 0) session1 := env.NewSession() @@ -418,7 +416,7 @@ func TestSessionStart_InformationalMessageNoConcurrentSessions(t *testing.T) { env.GitCommit("Initial commit") env.GitCheckoutNewBranch("feature/test") - env.InitEntire(strategy.StrategyNameManualCommit) + env.InitEntire() // Start a single session (no other sessions) session1 := env.NewSession() diff --git a/cmd/entire/cli/integration_test/setup_claude_hooks_test.go b/cmd/entire/cli/integration_test/setup_claude_hooks_test.go index 073375cea..276065cc8 100644 --- a/cmd/entire/cli/integration_test/setup_claude_hooks_test.go +++ b/cmd/entire/cli/integration_test/setup_claude_hooks_test.go @@ -39,7 +39,7 @@ func TestSetupClaudeHooks_AddsAllRequiredHooks(t *testing.T) { t.Parallel() env := NewTestEnv(t) env.InitRepo() - env.InitEntire("manual-commit") // Sets up .entire/settings.json + env.InitEntire() // Sets up .entire/settings.json // Create initial commit (required for setup) env.WriteFile("README.md", "# Test") @@ -83,7 +83,7 @@ func TestSetupClaudeHooks_PreservesExistingSettings(t *testing.T) { t.Parallel() env := NewTestEnv(t) env.InitRepo() - env.InitEntire("manual-commit") + env.InitEntire() env.WriteFile("README.md", "# Test") env.GitAdd("README.md") diff --git a/cmd/entire/cli/integration_test/setup_cmd_test.go b/cmd/entire/cli/integration_test/setup_cmd_test.go index ef612b1c4..062fe0c55 100644 --- a/cmd/entire/cli/integration_test/setup_cmd_test.go +++ b/cmd/entire/cli/integration_test/setup_cmd_test.go @@ -65,114 +65,109 @@ func (env *TestEnv) SetEnabled(enabled bool) { func TestEnableDisable(t *testing.T) { t.Parallel() - RunForAllStrategiesWithBasicEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Initially should be enabled (default) - stdout := env.RunCLI("status") - if !strings.Contains(stdout, "Enabled") { - t.Errorf("Expected status to show 'Enabled', got: %s", stdout) - } + env := NewRepoEnv(t) + // Initially should be enabled (default) + stdout := env.RunCLI("status") + if !strings.Contains(stdout, "Enabled") { + t.Errorf("Expected status to show 'Enabled', got: %s", stdout) + } - // Disable (using --project so re-enable can override cleanly) - stdout = env.RunCLI("disable", "--project") - if !strings.Contains(stdout, "disabled") { - t.Errorf("Expected disable output to contain 'disabled', got: %s", stdout) - } + // Disable (using --project so re-enable can override cleanly) + stdout = env.RunCLI("disable", "--project") + if !strings.Contains(stdout, "disabled") { + t.Errorf("Expected disable output to contain 'disabled', got: %s", stdout) + } - // Check status is now disabled - stdout = env.RunCLI("status") - if !strings.Contains(stdout, "Disabled") { - t.Errorf("Expected status to show 'Disabled', got: %s", stdout) - } + // Check status is now disabled + stdout = env.RunCLI("status") + if !strings.Contains(stdout, "Disabled") { + t.Errorf("Expected status to show 'Disabled', got: %s", stdout) + } - // Re-enable (using --agent for non-interactive mode) - stdout = env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") - if !strings.Contains(stdout, "Ready.") { - t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) - } + // Re-enable (using --agent for non-interactive mode) + stdout = env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") + if !strings.Contains(stdout, "Ready.") { + t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) + } - // Check status is now enabled - stdout = env.RunCLI("status") - if !strings.Contains(stdout, "Enabled") { - t.Errorf("Expected status to show 'Enabled', got: %s", stdout) - } - }) + // Check status is now enabled + stdout = env.RunCLI("status") + if !strings.Contains(stdout, "Enabled") { + t.Errorf("Expected status to show 'Enabled', got: %s", stdout) + } } func TestRewindBlockedWhenDisabled(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Disable Entire - env.SetEnabled(false) - - // Try to run rewind --list - should show disabled message (not error) - stdout, err := env.RunCLIWithError("rewind", "--list") - if err != nil { - t.Fatalf("rewind --list command failed unexpectedly: %v\nOutput: %s", err, stdout) - } - if !strings.Contains(stdout, "Entire is disabled") { - t.Errorf("Expected disabled message, got: %s", stdout) - } - if !strings.Contains(stdout, "entire enable") { - t.Errorf("Expected message to mention 'entire enable', got: %s", stdout) - } - }) + env := NewRepoWithCommit(t) + // Disable Entire + env.SetEnabled(false) + + // Try to run rewind --list - should show disabled message (not error) + stdout, err := env.RunCLIWithError("rewind", "--list") + if err != nil { + t.Fatalf("rewind --list command failed unexpectedly: %v\nOutput: %s", err, stdout) + } + if !strings.Contains(stdout, "Entire is disabled") { + t.Errorf("Expected disabled message, got: %s", stdout) + } + if !strings.Contains(stdout, "entire enable") { + t.Errorf("Expected message to mention 'entire enable', got: %s", stdout) + } } func TestHooksSilentWhenDisabled(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create an untracked file - env.WriteFile("newfile.txt", "content") + env := NewRepoWithCommit(t) + // Create an untracked file + env.WriteFile("newfile.txt", "content") - // Disable Entire - env.SetEnabled(false) + // Disable Entire + env.SetEnabled(false) - // Run hook - should exit silently (no error, no state file created) - err := env.SimulateUserPromptSubmit("test-session-disabled") - if err != nil { - t.Fatalf("Hook should exit silently when disabled, got error: %v", err) - } + // Run hook - should exit silently (no error, no state file created) + err := env.SimulateUserPromptSubmit("test-session-disabled") + if err != nil { + t.Fatalf("Hook should exit silently when disabled, got error: %v", err) + } - // Verify no state file was created (hook exited early) - statePath := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-prompt-test-session-disabled.json") - if _, err := os.Stat(statePath); err == nil { - t.Error("pre-prompt state file should NOT exist when disabled") - } - }) + // Verify no state file was created (hook exited early) + statePath := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-prompt-test-session-disabled.json") + if _, err := os.Stat(statePath); err == nil { + t.Error("pre-prompt state file should NOT exist when disabled") + } } func TestStatusWhenDisabled(t *testing.T) { t.Parallel() - RunForAllStrategiesWithBasicEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Disable Entire - env.SetEnabled(false) - - // Status command should still work and show disabled - stdout := env.RunCLI("status") - if !strings.Contains(stdout, "Disabled") { - t.Errorf("Expected status to show 'Disabled', got: %s", stdout) - } - }) + env := NewRepoEnv(t) + // Disable Entire + env.SetEnabled(false) + + // Status command should still work and show disabled + stdout := env.RunCLI("status") + if !strings.Contains(stdout, "Disabled") { + t.Errorf("Expected status to show 'Disabled', got: %s", stdout) + } } func TestEnableWhenDisabled(t *testing.T) { t.Parallel() - RunForAllStrategiesWithBasicEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Disable Entire - env.SetEnabled(false) - - // Enable command should work (using --agent for non-interactive mode) - stdout := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") - if !strings.Contains(stdout, "Ready.") { - t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) - } + env := NewRepoEnv(t) + // Disable Entire + env.SetEnabled(false) - // Verify it's now enabled - stdout = env.RunCLI("status") - if !strings.Contains(stdout, "Enabled") { - t.Errorf("Expected status to show 'Enabled' after re-enabling, got: %s", stdout) - } - }) + // Enable command should work (using --agent for non-interactive mode) + stdout := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") + if !strings.Contains(stdout, "Ready.") { + t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) + } + + // Verify it's now enabled + stdout = env.RunCLI("status") + if !strings.Contains(stdout, "Enabled") { + t.Errorf("Expected status to show 'Enabled' after re-enabling, got: %s", stdout) + } } func TestEnableDefaultStrategy(t *testing.T) { diff --git a/cmd/entire/cli/integration_test/setup_gemini_hooks_test.go b/cmd/entire/cli/integration_test/setup_gemini_hooks_test.go index 6d4dbb5b7..1a5bf3eeb 100644 --- a/cmd/entire/cli/integration_test/setup_gemini_hooks_test.go +++ b/cmd/entire/cli/integration_test/setup_gemini_hooks_test.go @@ -20,7 +20,7 @@ func TestSetupGeminiHooks_AddsAllRequiredHooks(t *testing.T) { t.Parallel() env := NewTestEnv(t) env.InitRepo() - env.InitEntire("manual-commit") // Sets up .entire/settings.json + env.InitEntire() // Sets up .entire/settings.json // Create initial commit (required for setup) env.WriteFile("README.md", "# Test") @@ -83,7 +83,7 @@ func TestSetupGeminiHooks_PreservesExistingSettings(t *testing.T) { t.Parallel() env := NewTestEnv(t) env.InitRepo() - env.InitEntire("manual-commit") + env.InitEntire() env.WriteFile("README.md", "# Test") env.GitAdd("README.md") diff --git a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go index da96068b2..7b01edd2d 100644 --- a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go +++ b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go @@ -26,268 +26,264 @@ import ( // 3. PostTask creates the final task checkpoint commit func TestSubagentCheckpoints_FullFlow(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a session - session := env.NewSession() - - // Create transcript (needed by hooks) - session.CreateTranscript("Implement feature X", []FileChange{ - {Path: "feature.go", Content: "package main"}, - }) - - // Simulate user prompt submit first (captures pre-prompt state) - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + env := NewFeatureBranchEnv(t) + // Create a session + session := env.NewSession() - // Task tool use ID (simulates Claude's Task tool invocation) - taskToolUseID := "toolu_01TaskABC123" + // Create transcript (needed by hooks) + session.CreateTranscript("Implement feature X", []FileChange{ + {Path: "feature.go", Content: "package main"}, + }) - // Step 1: PreTask - creates pre-task file - err = env.SimulatePreTask(session.ID, session.TranscriptPath, taskToolUseID) - if err != nil { - t.Fatalf("SimulatePreTask failed: %v", err) - } + // Simulate user prompt submit first (captures pre-prompt state) + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - // Verify pre-task file was created - preTaskFile := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-task-"+taskToolUseID+".json") - if _, err := os.Stat(preTaskFile); os.IsNotExist(err) { - t.Error("pre-task file should exist after SimulatePreTask") - } + // Task tool use ID (simulates Claude's Task tool invocation) + taskToolUseID := "toolu_01TaskABC123" - // Step 2: PostTodo - simulate TodoWrite calls with file changes between them - // Note: Only PostTodo calls that detect file changes will create incremental commits - - // First TodoWrite - no file changes, should be skipped - err = env.SimulatePostTodo(PostTodoInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: "toolu_01TodoWrite001", - Todos: []Todo{ - {Content: "Create feature file", Status: "in_progress", ActiveForm: "Creating feature file"}, - {Content: "Write tests", Status: "pending", ActiveForm: "Writing tests"}, - }, - }) - if err != nil { - t.Fatalf("SimulatePostTodo failed for first todo: %v", err) - } + // Step 1: PreTask - creates pre-task file + err = env.SimulatePreTask(session.ID, session.TranscriptPath, taskToolUseID) + if err != nil { + t.Fatalf("SimulatePreTask failed: %v", err) + } - // Create a file change - env.WriteFile("feature.go", "package main\n\nfunc Feature() {}\n") - - // Second TodoWrite - should create incremental checkpoint (has file changes) - err = env.SimulatePostTodo(PostTodoInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: "toolu_01TodoWrite002", - Todos: []Todo{ - {Content: "Create feature file", Status: "completed", ActiveForm: "Creating feature file"}, - {Content: "Write tests", Status: "in_progress", ActiveForm: "Writing tests"}, - }, - }) - if err != nil { - t.Fatalf("SimulatePostTodo failed for second todo: %v", err) - } + // Verify pre-task file was created + preTaskFile := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-task-"+taskToolUseID+".json") + if _, err := os.Stat(preTaskFile); os.IsNotExist(err) { + t.Error("pre-task file should exist after SimulatePreTask") + } - // Create another file change - env.WriteFile("feature_test.go", "package main\n\nimport \"testing\"\n\nfunc TestFeature(t *testing.T) {}\n") - - // Third TodoWrite - should create another incremental checkpoint - err = env.SimulatePostTodo(PostTodoInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: "toolu_01TodoWrite003", - Todos: []Todo{ - {Content: "Create feature file", Status: "completed", ActiveForm: "Creating feature file"}, - {Content: "Write tests", Status: "completed", ActiveForm: "Writing tests"}, - }, - }) - if err != nil { - t.Fatalf("SimulatePostTodo failed for third todo: %v", err) - } + // Step 2: PostTodo - simulate TodoWrite calls with file changes between them + // Note: Only PostTodo calls that detect file changes will create incremental commits + + // First TodoWrite - no file changes, should be skipped + err = env.SimulatePostTodo(PostTodoInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: "toolu_01TodoWrite001", + Todos: []Todo{ + {Content: "Create feature file", Status: "in_progress", ActiveForm: "Creating feature file"}, + {Content: "Write tests", Status: "pending", ActiveForm: "Writing tests"}, + }, + }) + if err != nil { + t.Fatalf("SimulatePostTodo failed for first todo: %v", err) + } - // Step 3: PostTask - creates final task checkpoint - err = env.SimulatePostTask(PostTaskInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: taskToolUseID, - AgentID: "agent-123", - }) - if err != nil { - t.Fatalf("SimulatePostTask failed: %v", err) - } + // Create a file change + env.WriteFile("feature.go", "package main\n\nfunc Feature() {}\n") + + // Second TodoWrite - should create incremental checkpoint (has file changes) + err = env.SimulatePostTodo(PostTodoInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: "toolu_01TodoWrite002", + Todos: []Todo{ + {Content: "Create feature file", Status: "completed", ActiveForm: "Creating feature file"}, + {Content: "Write tests", Status: "in_progress", ActiveForm: "Writing tests"}, + }, + }) + if err != nil { + t.Fatalf("SimulatePostTodo failed for second todo: %v", err) + } - // Verify pre-task file is cleaned up - if _, err := os.Stat(preTaskFile); !os.IsNotExist(err) { - t.Error("Pre-task file should be removed after PostTask") - } + // Create another file change + env.WriteFile("feature_test.go", "package main\n\nimport \"testing\"\n\nfunc TestFeature(t *testing.T) {}\n") + + // Third TodoWrite - should create another incremental checkpoint + err = env.SimulatePostTodo(PostTodoInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: "toolu_01TodoWrite003", + Todos: []Todo{ + {Content: "Create feature file", Status: "completed", ActiveForm: "Creating feature file"}, + {Content: "Write tests", Status: "completed", ActiveForm: "Writing tests"}, + }, + }) + if err != nil { + t.Fatalf("SimulatePostTodo failed for third todo: %v", err) + } - // Verify checkpoints are stored in final location (strategy-specific) - verifyCheckpointStorage(t, env, strategyName, session.ID, taskToolUseID) + // Step 3: PostTask - creates final task checkpoint + err = env.SimulatePostTask(PostTaskInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: taskToolUseID, + AgentID: "agent-123", }) + if err != nil { + t.Fatalf("SimulatePostTask failed: %v", err) + } + + // Verify pre-task file is cleaned up + if _, err := os.Stat(preTaskFile); !os.IsNotExist(err) { + t.Error("Pre-task file should be removed after PostTask") + } + + // Verify checkpoints are stored in final location (strategy-specific) + verifyCheckpointStorage(t, env, session.ID, taskToolUseID) } // TestSubagentCheckpoints_NoFileChanges tests that PostTodo is skipped when no file changes func TestSubagentCheckpoints_NoFileChanges(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a session - session := env.NewSession() + env := NewFeatureBranchEnv(t) + // Create a session + session := env.NewSession() - // Create transcript - session.CreateTranscript("Quick task", []FileChange{}) + // Create transcript + session.CreateTranscript("Quick task", []FileChange{}) - // Simulate user prompt submit - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Simulate user prompt submit + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - // Create pre-task file to simulate subagent context - taskToolUseID := "toolu_01TaskNoChanges" - err = env.SimulatePreTask(session.ID, session.TranscriptPath, taskToolUseID) - if err != nil { - t.Fatalf("SimulatePreTask failed: %v", err) - } + // Create pre-task file to simulate subagent context + taskToolUseID := "toolu_01TaskNoChanges" + err = env.SimulatePreTask(session.ID, session.TranscriptPath, taskToolUseID) + if err != nil { + t.Fatalf("SimulatePreTask failed: %v", err) + } - // Get git log before PostTodo - beforeCommits := env.GetGitLog() - - // Call PostTodo WITHOUT making any file changes - err = env.SimulatePostTodo(PostTodoInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: "toolu_01TodoWriteNoChange", - Todos: []Todo{ - {Content: "Some task", Status: "pending", ActiveForm: "Doing task"}, - }, - }) - if err != nil { - t.Fatalf("SimulatePostTodo should not fail: %v", err) - } + // Get git log before PostTodo + beforeCommits := env.GetGitLog() + + // Call PostTodo WITHOUT making any file changes + err = env.SimulatePostTodo(PostTodoInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: "toolu_01TodoWriteNoChange", + Todos: []Todo{ + {Content: "Some task", Status: "pending", ActiveForm: "Doing task"}, + }, + }) + if err != nil { + t.Fatalf("SimulatePostTodo should not fail: %v", err) + } - // Get git log after PostTodo - afterCommits := env.GetGitLog() + // Get git log after PostTodo + afterCommits := env.GetGitLog() - // Verify no new commits were created - if len(afterCommits) != len(beforeCommits) { - t.Errorf("Expected no new commits when no file changes, before=%d after=%d", len(beforeCommits), len(afterCommits)) - } - }) + // Verify no new commits were created + if len(afterCommits) != len(beforeCommits) { + t.Errorf("Expected no new commits when no file changes, before=%d after=%d", len(beforeCommits), len(afterCommits)) + } } // TestSubagentCheckpoints_PostTaskNoFileChanges tests that PostTask is skipped when no file changes // and the pre-task state is still cleaned up. func TestSubagentCheckpoints_PostTaskNoFileChanges(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a session - session := env.NewSession() + env := NewFeatureBranchEnv(t) + // Create a session + session := env.NewSession() - // Create transcript (no file changes in transcript either) - session.CreateTranscript("Quick task with no file changes", []FileChange{}) + // Create transcript (no file changes in transcript either) + session.CreateTranscript("Quick task with no file changes", []FileChange{}) - // Simulate user prompt submit - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Simulate user prompt submit + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - // Create pre-task file to simulate subagent context - taskToolUseID := "toolu_01TaskNoFileChanges" - err = env.SimulatePreTask(session.ID, session.TranscriptPath, taskToolUseID) - if err != nil { - t.Fatalf("SimulatePreTask failed: %v", err) - } + // Create pre-task file to simulate subagent context + taskToolUseID := "toolu_01TaskNoFileChanges" + err = env.SimulatePreTask(session.ID, session.TranscriptPath, taskToolUseID) + if err != nil { + t.Fatalf("SimulatePreTask failed: %v", err) + } - // Verify pre-task file was created - preTaskFile := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-task-"+taskToolUseID+".json") - if _, err := os.Stat(preTaskFile); os.IsNotExist(err) { - t.Fatal("pre-task file should exist after SimulatePreTask") - } + // Verify pre-task file was created + preTaskFile := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-task-"+taskToolUseID+".json") + if _, err := os.Stat(preTaskFile); os.IsNotExist(err) { + t.Fatal("pre-task file should exist after SimulatePreTask") + } - // Get git log before PostTask - beforeCommits := env.GetGitLog() - - // Call PostTask WITHOUT making any file changes - err = env.SimulatePostTask(PostTaskInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: taskToolUseID, - AgentID: "agent-no-changes", - }) - if err != nil { - t.Fatalf("SimulatePostTask should not fail: %v", err) - } + // Get git log before PostTask + beforeCommits := env.GetGitLog() + + // Call PostTask WITHOUT making any file changes + err = env.SimulatePostTask(PostTaskInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: taskToolUseID, + AgentID: "agent-no-changes", + }) + if err != nil { + t.Fatalf("SimulatePostTask should not fail: %v", err) + } - // Get git log after PostTask - afterCommits := env.GetGitLog() + // Get git log after PostTask + afterCommits := env.GetGitLog() - // Verify no new commits were created on the main branch - if len(afterCommits) != len(beforeCommits) { - t.Errorf("Expected no new commits when no file changes, before=%d after=%d", len(beforeCommits), len(afterCommits)) - } + // Verify no new commits were created on the main branch + if len(afterCommits) != len(beforeCommits) { + t.Errorf("Expected no new commits when no file changes, before=%d after=%d", len(beforeCommits), len(afterCommits)) + } - // Verify pre-task file is cleaned up even though no checkpoint was created - if _, err := os.Stat(preTaskFile); !os.IsNotExist(err) { - t.Error("Pre-task file should be removed after PostTask even with no file changes") - } - }) + // Verify pre-task file is cleaned up even though no checkpoint was created + if _, err := os.Stat(preTaskFile); !os.IsNotExist(err) { + t.Error("Pre-task file should be removed after PostTask even with no file changes") + } } // TestSubagentCheckpoints_NoPreTaskFile tests that PostTodo is a no-op // when there's no active pre-task file (main agent context). func TestSubagentCheckpoints_NoPreTaskFile(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a session - session := env.NewSession() + env := NewFeatureBranchEnv(t) + // Create a session + session := env.NewSession() - // Create transcript - session.CreateTranscript("Quick task", []FileChange{}) + // Create transcript + session.CreateTranscript("Quick task", []FileChange{}) - // Simulate user prompt submit - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } + // Simulate user prompt submit + err := env.SimulateUserPromptSubmit(session.ID) + if err != nil { + t.Fatalf("SimulateUserPromptSubmit failed: %v", err) + } - // Create a file change so that PostTodo would trigger if in subagent context - env.WriteFile("test.txt", "content") - - // Get git log before PostTodo - beforeCommits := env.GetGitLog() - - // Call PostTodo WITHOUT calling PreTask first - // This simulates a TodoWrite from the main agent (not a subagent) - err = env.SimulatePostTodo(PostTodoInput{ - SessionID: session.ID, - TranscriptPath: session.TranscriptPath, - ToolUseID: "toolu_01MainAgentTodo", - Todos: []Todo{ - {Content: "Some task", Status: "pending", ActiveForm: "Doing task"}, - }, - }) - if err != nil { - t.Fatalf("SimulatePostTodo should not fail: %v", err) - } + // Create a file change so that PostTodo would trigger if in subagent context + env.WriteFile("test.txt", "content") + + // Get git log before PostTodo + beforeCommits := env.GetGitLog() + + // Call PostTodo WITHOUT calling PreTask first + // This simulates a TodoWrite from the main agent (not a subagent) + err = env.SimulatePostTodo(PostTodoInput{ + SessionID: session.ID, + TranscriptPath: session.TranscriptPath, + ToolUseID: "toolu_01MainAgentTodo", + Todos: []Todo{ + {Content: "Some task", Status: "pending", ActiveForm: "Doing task"}, + }, + }) + if err != nil { + t.Fatalf("SimulatePostTodo should not fail: %v", err) + } - // Get git log after PostTodo - afterCommits := env.GetGitLog() + // Get git log after PostTodo + afterCommits := env.GetGitLog() - // Verify no new commits were created (not in subagent context) - if len(afterCommits) != len(beforeCommits) { - t.Errorf("Expected no new commits when not in subagent context, before=%d after=%d", len(beforeCommits), len(afterCommits)) - } - }) + // Verify no new commits were created (not in subagent context) + if len(afterCommits) != len(beforeCommits) { + t.Errorf("Expected no new commits when not in subagent context, before=%d after=%d", len(beforeCommits), len(afterCommits)) + } } // verifyCheckpointStorage verifies that checkpoints are stored in the correct // location based on the strategy type. // Note: Incremental checkpoints are stored in separate commits during task execution, // while the final checkpoint.json is created at PostTask time. -func verifyCheckpointStorage(t *testing.T, env *TestEnv, strategyName, sessionID, taskToolUseID string) { +func verifyCheckpointStorage(t *testing.T, env *TestEnv, sessionID, taskToolUseID string) { t.Helper() // Manual-commit stores checkpoints in git tree on shadow branch (entire/) diff --git a/cmd/entire/cli/integration_test/subdirectory_test.go b/cmd/entire/cli/integration_test/subdirectory_test.go index a73f8d1d4..a447b76fe 100644 --- a/cmd/entire/cli/integration_test/subdirectory_test.go +++ b/cmd/entire/cli/integration_test/subdirectory_test.go @@ -19,134 +19,129 @@ import ( // create frontend/.entire/ instead of using the repo root's .entire/. func TestSubdirectory_EntireDirCreatedAtRepoRoot(t *testing.T) { t.Parallel() - RunForAllStrategiesWithRepoEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a subdirectory to simulate running from frontend/ - subdirName := "frontend" - subdirPath := filepath.Join(env.RepoDir, subdirName) - if err := os.MkdirAll(subdirPath, 0o755); err != nil { - t.Fatalf("failed to create subdirectory: %v", err) - } - - // Run the user-prompt-submit hook FROM the subdirectory - sessionID := "test-subdir-session" - input := map[string]string{ - "session_id": sessionID, - "transcript_path": "", - } - inputJSON, err := json.Marshal(input) - if err != nil { - t.Fatalf("failed to marshal input: %v", err) - } - - cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") - cmd.Dir = subdirPath // Run from subdirectory! - cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("hook failed: %v\nOutput: %s", err, output) - } - - // Verify .entire/tmp was NOT created in the subdirectory - subdirEntire := filepath.Join(subdirPath, ".entire") - if _, err := os.Stat(subdirEntire); !os.IsNotExist(err) { - t.Errorf(".entire directory should NOT exist in subdirectory %s, but it does", subdirName) - } - - // Verify .entire/tmp WAS created at the repo root - rootEntireTmp := filepath.Join(env.RepoDir, ".entire", "tmp") - if _, err := os.Stat(rootEntireTmp); os.IsNotExist(err) { - t.Errorf(".entire/tmp should exist at repo root, but it doesn't") - } - - // Verify the pre-prompt state file was created at repo root - stateFile := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-prompt-"+sessionID+".json") - if _, err := os.Stat(stateFile); os.IsNotExist(err) { - t.Errorf("pre-prompt state file should exist at %s, but it doesn't", stateFile) - } - - // Also verify the state file was NOT created in the subdirectory - subdirStateFile := filepath.Join(subdirPath, ".entire", "tmp", "pre-prompt-"+sessionID+".json") - if _, err := os.Stat(subdirStateFile); !os.IsNotExist(err) { - t.Errorf("pre-prompt state file should NOT exist in subdirectory at %s", subdirStateFile) - } - }) + env := NewRepoWithCommit(t) + // Create a subdirectory to simulate running from frontend/ + subdirName := "frontend" + subdirPath := filepath.Join(env.RepoDir, subdirName) + if err := os.MkdirAll(subdirPath, 0o755); err != nil { + t.Fatalf("failed to create subdirectory: %v", err) + } + + // Run the user-prompt-submit hook FROM the subdirectory + sessionID := "test-subdir-session" + input := map[string]string{ + "session_id": sessionID, + "transcript_path": "", + } + inputJSON, err := json.Marshal(input) + if err != nil { + t.Fatalf("failed to marshal input: %v", err) + } + + cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") + cmd.Dir = subdirPath // Run from subdirectory! + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("hook failed: %v\nOutput: %s", err, output) + } + + // Verify .entire/tmp was NOT created in the subdirectory + subdirEntire := filepath.Join(subdirPath, ".entire") + if _, err := os.Stat(subdirEntire); !os.IsNotExist(err) { + t.Errorf(".entire directory should NOT exist in subdirectory %s, but it does", subdirName) + } + + // Verify .entire/tmp WAS created at the repo root + rootEntireTmp := filepath.Join(env.RepoDir, ".entire", "tmp") + if _, err := os.Stat(rootEntireTmp); os.IsNotExist(err) { + t.Errorf(".entire/tmp should exist at repo root, but it doesn't") + } + + // Verify the pre-prompt state file was created at repo root + stateFile := filepath.Join(env.RepoDir, ".entire", "tmp", "pre-prompt-"+sessionID+".json") + if _, err := os.Stat(stateFile); os.IsNotExist(err) { + t.Errorf("pre-prompt state file should exist at %s, but it doesn't", stateFile) + } + + // Also verify the state file was NOT created in the subdirectory + subdirStateFile := filepath.Join(subdirPath, ".entire", "tmp", "pre-prompt-"+sessionID+".json") + if _, err := os.Stat(subdirStateFile); !os.IsNotExist(err) { + t.Errorf("pre-prompt state file should NOT exist in subdirectory at %s", subdirStateFile) + } } // TestSubdirectory_SaveStepFromSubdir verifies that SaveStep (stop hook) // works correctly when run from a subdirectory. func TestSubdirectory_SaveStepFromSubdir(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Create a subdirectory - subdirName := "src" - subdirPath := filepath.Join(env.RepoDir, subdirName) - if err := os.MkdirAll(subdirPath, 0o755); err != nil { - t.Fatalf("failed to create subdirectory: %v", err) - } - - // Create a session and files - session := env.NewSession() - - // Create a file in the subdirectory (as if Claude wrote it there) - env.WriteFile(filepath.Join(subdirName, "app.js"), "console.log('hello');") - - // Create transcript - session.CreateTranscript("Create app.js", []FileChange{ - {Path: filepath.Join(subdirName, "app.js"), Content: "console.log('hello');"}, - }) - - // Simulate user-prompt-submit FROM subdirectory - input := map[string]string{ - "session_id": session.ID, - "transcript_path": "", - } - inputJSON, _ := json.Marshal(input) - - cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") - cmd.Dir = subdirPath // Run from subdirectory - cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("user-prompt-submit hook failed: %v\nOutput: %s", err, output) - } - - // Simulate stop FROM subdirectory - stopInput := map[string]string{ - "session_id": session.ID, - "transcript_path": session.TranscriptPath, - } - stopInputJSON, _ := json.Marshal(stopInput) - - stopCmd := exec.Command(getTestBinary(), "hooks", "claude-code", "stop") - stopCmd.Dir = subdirPath // Run from subdirectory - stopCmd.Stdin = bytes.NewReader(stopInputJSON) - stopCmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - if output, err := stopCmd.CombinedOutput(); err != nil { - t.Fatalf("stop hook failed: %v\nOutput: %s", err, output) - } - - // Verify .entire was NOT created in subdirectory - subdirEntire := filepath.Join(subdirPath, ".entire") - if _, err := os.Stat(subdirEntire); !os.IsNotExist(err) { - t.Errorf(".entire directory should NOT exist in subdirectory %s", subdirName) - } - - // Verify we can get rewind points (this uses ListSessions/GetRewindPoints) - points := env.GetRewindPoints() - // Shadow strategy should have at least one rewind point - // Other strategies create commits - if strategyName == "manual-commit" { - if len(points) == 0 { - t.Error("expected at least one rewind point after save") - } - } + env := NewFeatureBranchEnv(t) + // Create a subdirectory + subdirName := "src" + subdirPath := filepath.Join(env.RepoDir, subdirName) + if err := os.MkdirAll(subdirPath, 0o755); err != nil { + t.Fatalf("failed to create subdirectory: %v", err) + } + + // Create a session and files + session := env.NewSession() + + // Create a file in the subdirectory (as if Claude wrote it there) + env.WriteFile(filepath.Join(subdirName, "app.js"), "console.log('hello');") + + // Create transcript + session.CreateTranscript("Create app.js", []FileChange{ + {Path: filepath.Join(subdirName, "app.js"), Content: "console.log('hello');"}, }) + + // Simulate user-prompt-submit FROM subdirectory + input := map[string]string{ + "session_id": session.ID, + "transcript_path": "", + } + inputJSON, _ := json.Marshal(input) + + cmd := exec.Command(getTestBinary(), "hooks", "claude-code", "user-prompt-submit") + cmd.Dir = subdirPath // Run from subdirectory + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("user-prompt-submit hook failed: %v\nOutput: %s", err, output) + } + + // Simulate stop FROM subdirectory + stopInput := map[string]string{ + "session_id": session.ID, + "transcript_path": session.TranscriptPath, + } + stopInputJSON, _ := json.Marshal(stopInput) + + stopCmd := exec.Command(getTestBinary(), "hooks", "claude-code", "stop") + stopCmd.Dir = subdirPath // Run from subdirectory + stopCmd.Stdin = bytes.NewReader(stopInputJSON) + stopCmd.Env = append(os.Environ(), + "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, + ) + if output, err := stopCmd.CombinedOutput(); err != nil { + t.Fatalf("stop hook failed: %v\nOutput: %s", err, output) + } + + // Verify .entire was NOT created in subdirectory + subdirEntire := filepath.Join(subdirPath, ".entire") + if _, err := os.Stat(subdirEntire); !os.IsNotExist(err) { + t.Errorf(".entire directory should NOT exist in subdirectory %s", subdirName) + } + + // Verify we can get rewind points (this uses ListSessions/GetRewindPoints) + points := env.GetRewindPoints() + // Shadow strategy should have at least one rewind point + if len(points) == 0 { + t.Error("expected at least one rewind point after save") + } } diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index 532507b5c..cb93db8cb 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -20,7 +20,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/trailers" "github.com/go-git/go-git/v5" @@ -161,19 +160,19 @@ func (env *TestEnv) RunCLIWithStdin(stdin string, args ...string) string { // NewRepoEnv creates a TestEnv with an initialized git repo and Entire. // This is a convenience factory for tests that need a basic repo setup. -func NewRepoEnv(t *testing.T, strategy string) *TestEnv { +func NewRepoEnv(t *testing.T) *TestEnv { t.Helper() env := NewTestEnv(t) env.InitRepo() - env.InitEntire(strategy) + env.InitEntire() return env } // NewRepoWithCommit creates a TestEnv with a git repo, Entire, and an initial commit. // The initial commit contains a README.md and .gitignore (excluding .entire/). -func NewRepoWithCommit(t *testing.T, strategy string) *TestEnv { +func NewRepoWithCommit(t *testing.T) *TestEnv { t.Helper() - env := NewRepoEnv(t, strategy) + env := NewRepoEnv(t) env.WriteFile(".gitignore", ".entire/\n") env.WriteFile("README.md", "# Test Repository") env.GitAdd(".gitignore") @@ -186,77 +185,13 @@ func NewRepoWithCommit(t *testing.T, strategy string) *TestEnv { // It initializes the repo, creates an initial commit on main, // and checks out a feature branch. This is the most common setup // for session and rewind tests since Entire tracking skips main/master. -func NewFeatureBranchEnv(t *testing.T, strategyName string) *TestEnv { +func NewFeatureBranchEnv(t *testing.T) *TestEnv { t.Helper() - env := NewRepoWithCommit(t, strategyName) + env := NewRepoWithCommit(t) env.GitCheckoutNewBranch("feature/test-branch") return env } -// AllStrategies returns all strategy names for parameterized tests. -func AllStrategies() []string { - return []string{ - strategy.StrategyNameManualCommit, - } -} - -// RunForAllStrategies runs a test function for each strategy in parallel. -// This reduces boilerplate for tests that need to verify behavior across all strategies. -// Each subtest gets its own TestEnv with a feature branch ready for testing. -func RunForAllStrategies(t *testing.T, testFn func(t *testing.T, env *TestEnv, strategyName string)) { - t.Helper() - for _, strat := range AllStrategies() { - strat := strat // capture for parallel - t.Run(strat, func(t *testing.T) { - t.Parallel() - env := NewFeatureBranchEnv(t, strat) - testFn(t, env, strat) - }) - } -} - -// RunForAllStrategiesWithRepoEnv runs a test function for each strategy in parallel, -// using NewRepoWithCommit instead of NewFeatureBranchEnv. Use this for tests -// that need to test behavior on the main branch. -func RunForAllStrategiesWithRepoEnv(t *testing.T, testFn func(t *testing.T, env *TestEnv, strategyName string)) { - t.Helper() - for _, strat := range AllStrategies() { - strat := strat // capture for parallel - t.Run(strat, func(t *testing.T) { - t.Parallel() - env := NewRepoWithCommit(t, strat) - testFn(t, env, strat) - }) - } -} - -// RunForAllStrategiesWithBasicEnv runs a test function for each strategy in parallel, -// using NewRepoEnv (git repo + entire init, no commits). Use this for tests -// that need to verify basic initialization behavior. -func RunForAllStrategiesWithBasicEnv(t *testing.T, testFn func(t *testing.T, env *TestEnv, strategyName string)) { - t.Helper() - for _, strat := range AllStrategies() { - strat := strat // capture for parallel - t.Run(strat, func(t *testing.T) { - t.Parallel() - env := NewRepoEnv(t, strat) - testFn(t, env, strat) - }) - } -} - -// RunForStrategiesSequential runs a test function for specific strategies sequentially. -// Use this for tests that cannot be parallelized (e.g., tests using os.Chdir). -// The strategies parameter allows testing a subset of strategies. -func RunForStrategiesSequential(t *testing.T, strategies []string, testFn func(t *testing.T, strategyName string)) { - t.Helper() - for _, strat := range strategies { - t.Run(strat, func(t *testing.T) { - testFn(t, strat) - }) - } -} - // InitRepo initializes a git repository in the test environment. func (env *TestEnv) InitRepo() { env.T.Helper() @@ -286,32 +221,32 @@ func (env *TestEnv) InitRepo() { } // InitEntire initializes the .entire directory with the specified strategy. -func (env *TestEnv) InitEntire(strategyName string) { - env.InitEntireWithOptions(strategyName, nil) +func (env *TestEnv) InitEntire() { + env.InitEntireWithOptions(nil) } // InitEntireWithOptions initializes the .entire directory with the specified strategy and options. -func (env *TestEnv) InitEntireWithOptions(strategyName string, strategyOptions map[string]any) { +func (env *TestEnv) InitEntireWithOptions(strategyOptions map[string]any) { env.T.Helper() - env.initEntireInternal(strategyName, strategyOptions) + env.initEntireInternal(strategyOptions) } // InitEntireWithAgent initializes an Entire test environment with a specific agent. // The agent name is for test documentation only — the CLI resolves the agent from // hook commands and checkpoint metadata, not from settings.json. -func (env *TestEnv) InitEntireWithAgent(strategyName string, _ agent.AgentName) { +func (env *TestEnv) InitEntireWithAgent(_ agent.AgentName) { env.T.Helper() - env.initEntireInternal(strategyName, nil) + env.initEntireInternal(nil) } // InitEntireWithAgentAndOptions initializes Entire with the specified strategy, agent, and options. -func (env *TestEnv) InitEntireWithAgentAndOptions(strategyName string, _ agent.AgentName, strategyOptions map[string]any) { +func (env *TestEnv) InitEntireWithAgentAndOptions(_ agent.AgentName, strategyOptions map[string]any) { env.T.Helper() - env.initEntireInternal(strategyName, strategyOptions) + env.initEntireInternal(strategyOptions) } // initEntireInternal is the common implementation for InitEntire variants. -func (env *TestEnv) initEntireInternal(strategyName string, strategyOptions map[string]any) { +func (env *TestEnv) initEntireInternal(strategyOptions map[string]any) { env.T.Helper() // Create .entire directory structure diff --git a/cmd/entire/cli/integration_test/testenv_test.go b/cmd/entire/cli/integration_test/testenv_test.go index 0d47c02db..ce333e04b 100644 --- a/cmd/entire/cli/integration_test/testenv_test.go +++ b/cmd/entire/cli/integration_test/testenv_test.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) func TestNewTestEnv(t *testing.T) { @@ -49,31 +48,30 @@ func TestTestEnv_InitRepo(t *testing.T) { func TestTestEnv_InitEntire(t *testing.T) { t.Parallel() - RunForAllStrategiesWithBasicEnv(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Verify .entire directory exists - entireDir := filepath.Join(env.RepoDir, ".entire") - if _, err := os.Stat(entireDir); os.IsNotExist(err) { - t.Error(".entire directory should exist") - } - - // Verify settings file exists and contains enabled - settingsPath := filepath.Join(entireDir, paths.SettingsFileName) - data, err := os.ReadFile(settingsPath) - if err != nil { - t.Fatalf("failed to read %s: %v", paths.SettingsFileName, err) - } - - settingsContent := string(data) - if !strings.Contains(settingsContent, `"enabled"`) { - t.Errorf("settings.json should contain enabled field, got: %s", settingsContent) - } - - // Verify tmp directory exists - tmpDir := filepath.Join(entireDir, "tmp") - if _, err := os.Stat(tmpDir); os.IsNotExist(err) { - t.Error(".entire/tmp directory should exist") - } - }) + env := NewRepoEnv(t) + // Verify .entire directory exists + entireDir := filepath.Join(env.RepoDir, ".entire") + if _, err := os.Stat(entireDir); os.IsNotExist(err) { + t.Error(".entire directory should exist") + } + + // Verify settings file exists and contains enabled + settingsPath := filepath.Join(entireDir, paths.SettingsFileName) + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("failed to read %s: %v", paths.SettingsFileName, err) + } + + settingsContent := string(data) + if !strings.Contains(settingsContent, `"enabled"`) { + t.Errorf("settings.json should contain enabled field, got: %s", settingsContent) + } + + // Verify tmp directory exists + tmpDir := filepath.Join(entireDir, "tmp") + if _, err := os.Stat(tmpDir); os.IsNotExist(err) { + t.Error(".entire/tmp directory should exist") + } } func TestTestEnv_WriteAndReadFile(t *testing.T) { @@ -160,7 +158,7 @@ func TestTestEnv_MultipleCommits(t *testing.T) { func TestNewRepoEnv(t *testing.T) { t.Parallel() - env := NewRepoEnv(t, strategy.StrategyNameManualCommit) + env := NewRepoEnv(t) // Verify .git directory exists gitDir := filepath.Join(env.RepoDir, ".git") @@ -177,7 +175,7 @@ func TestNewRepoEnv(t *testing.T) { func TestNewRepoWithCommit(t *testing.T) { t.Parallel() - env := NewRepoWithCommit(t, strategy.StrategyNameManualCommit) + env := NewRepoWithCommit(t) // Verify README exists if !env.FileExists("README.md") { @@ -193,7 +191,7 @@ func TestNewRepoWithCommit(t *testing.T) { func TestNewFeatureBranchEnv(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) + env := NewFeatureBranchEnv(t) // Verify we're on feature branch branch := env.GetCurrentBranch() @@ -206,42 +204,3 @@ func TestNewFeatureBranchEnv(t *testing.T) { t.Error("README.md should exist") } } - -func TestAllStrategies(t *testing.T) { - t.Parallel() - strategies := AllStrategies() - if len(strategies) != 1 { - t.Errorf("AllStrategies() returned %d strategies, want 2", len(strategies)) - } - - // Verify expected strategies are present - expected := []string{"manual-commit"} - for _, exp := range expected { - found := false - for _, s := range strategies { - if s == exp { - found = true - break - } - } - if !found { - t.Errorf("AllStrategies() missing %s", exp) - } - } -} - -func TestRunForAllStrategies(t *testing.T) { - t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // Verify we're on feature branch - branch := env.GetCurrentBranch() - if branch != "feature/test-branch" { - t.Errorf("GetCurrentBranch = %s, want feature/test-branch", branch) - } - - // Verify strategy was passed correctly - if strategyName == "" { - t.Error("strategyName should not be empty") - } - }) -} diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 68a6d100f..b99e960cf 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -78,10 +78,8 @@ func handleLifecycleSessionStart(ag agent.Agent, event *agent.Event) error { // Check for concurrent sessions and append count if any strat := GetStrategy() - if concurrentChecker, ok := strat.(strategy.ConcurrentSessionChecker); ok { - if count, err := concurrentChecker.CountOtherActiveSessionsWithCheckpoints(event.SessionID); err == nil && count > 0 { - message += fmt.Sprintf("\n %d other active conversation(s) in this workspace will also be included.\n Use 'entire status' for more information.", count) - } + if count, err := strat.CountOtherActiveSessionsWithCheckpoints(event.SessionID); err == nil && count > 0 { + message += fmt.Sprintf("\n %d other active conversation(s) in this workspace will also be included.\n Use 'entire status' for more information.", count) } // Output informational message @@ -136,11 +134,8 @@ func handleLifecycleTurnStart(ag agent.Agent, event *agent.Event) error { } strat := GetStrategy() - if initializer, ok := strat.(strategy.SessionInitializer); ok { - agentType := ag.Type() - if err := initializer.InitializeSession(sessionID, agentType, event.SessionRef, event.Prompt); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to initialize session state: %v\n", err) - } + if err := strat.InitializeSession(sessionID, ag.Type(), event.SessionRef, event.Prompt); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to initialize session state: %v\n", err) } return nil @@ -683,10 +678,8 @@ func transitionSessionTurnEnd(sessionID string) { // Always dispatch to strategy for turn-end handling. The strategy reads // work items from state (e.g. TurnCheckpointIDs), not the action list. strat := GetStrategy() - if handler, ok := strat.(strategy.TurnEndHandler); ok { - if err := handler.HandleTurnEnd(turnState); err != nil { - fmt.Fprintf(os.Stderr, "Warning: turn-end action dispatch failed: %v\n", err) - } + if err := strat.HandleTurnEnd(turnState); err != nil { + fmt.Fprintf(os.Stderr, "Warning: turn-end action dispatch failed: %v\n", err) } if updateErr := strategy.SaveSessionState(turnState); updateErr != nil { diff --git a/cmd/entire/cli/reset.go b/cmd/entire/cli/reset.go index a84c709a5..7e8858697 100644 --- a/cmd/entire/cli/reset.go +++ b/cmd/entire/cli/reset.go @@ -45,15 +45,9 @@ Without --force, prompts for confirmation before deleting.`, // Get current strategy strat := GetStrategy() - // Check if strategy supports reset - resetter, ok := strat.(strategy.SessionResetter) - if !ok { - return fmt.Errorf("strategy %s does not support reset", strat.Name()) - } - // Handle --session flag: reset a single session if sessionFlag != "" { - return runResetSession(cmd, resetter, sessionFlag, forceFlag) + return runResetSession(cmd, strat, sessionFlag, forceFlag) } // Check for active sessions before bulk reset @@ -98,7 +92,7 @@ Without --force, prompts for confirmation before deleting.`, } // Call strategy's Reset method - if err := resetter.Reset(); err != nil { + if err := strat.Reset(); err != nil { return fmt.Errorf("reset failed: %w", err) } @@ -113,7 +107,7 @@ Without --force, prompts for confirmation before deleting.`, } // runResetSession handles the --session flag: reset a single session. -func runResetSession(cmd *cobra.Command, resetter strategy.SessionResetter, sessionID string, force bool) error { +func runResetSession(cmd *cobra.Command, strat strategy.Strategy, sessionID string, force bool) error { // Verify the session exists state, err := strategy.LoadSessionState(sessionID) if err != nil { @@ -150,7 +144,7 @@ func runResetSession(cmd *cobra.Command, resetter strategy.SessionResetter, sess } } - if err := resetter.ResetSession(sessionID); err != nil { + if err := strat.ResetSession(sessionID); err != nil { return fmt.Errorf("reset session failed: %w", err) } diff --git a/cmd/entire/cli/resume.go b/cmd/entire/cli/resume.go index f61e3ae9c..97796120b 100644 --- a/cmd/entire/cli/resume.go +++ b/cmd/entire/cli/resume.go @@ -407,58 +407,50 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e strat := GetStrategy() // Use RestoreLogsOnly via LogsOnlyRestorer interface for multi-session support - if restorer, ok := strat.(strategy.LogsOnlyRestorer); ok { - // Create a logs-only rewind point with Agent populated (same as rewind) - point := strategy.RewindPoint{ - IsLogsOnly: true, - CheckpointID: checkpointID, - Agent: metadata.Agent, - } + // Create a logs-only rewind point with Agent populated (same as rewind) + point := strategy.RewindPoint{ + IsLogsOnly: true, + CheckpointID: checkpointID, + Agent: metadata.Agent, + } - sessions, restoreErr := restorer.RestoreLogsOnly(point, force) - if restoreErr != nil || len(sessions) == 0 { - // Fall back to single-session restore (e.g., old checkpoints without agent metadata) - return resumeSingleSession(ctx, ag, sessionID, checkpointID, repoRoot, force) - } + sessions, restoreErr := strat.RestoreLogsOnly(point, force) + if restoreErr != nil || len(sessions) == 0 { + // Fall back to single-session restore (e.g., old checkpoints without agent metadata) + return resumeSingleSession(ctx, ag, sessionID, checkpointID, repoRoot, force) + } - // Sort sessions by CreatedAt so the most recent is last (for display). - // This fixes ordering when subdirectory index doesn't reflect activity order. - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) - }) + // Sort sessions by CreatedAt so the most recent is last (for display). + // This fixes ordering when subdirectory index doesn't reflect activity order. + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].CreatedAt.Before(sessions[j].CreatedAt) + }) - logging.Debug(ctx, "resume session completed", - slog.String("checkpoint_id", checkpointID.String()), - slog.Int("session_count", len(sessions)), - ) + logging.Debug(ctx, "resume session completed", + slog.String("checkpoint_id", checkpointID.String()), + slog.Int("session_count", len(sessions)), + ) - // Print per-session resume commands using returned sessions - if len(sessions) > 1 { - fmt.Fprintf(os.Stderr, "\nRestored %d sessions. To continue, run:\n", len(sessions)) - } else if len(sessions) == 1 { - fmt.Fprintf(os.Stderr, "Session: %s\n", sessions[0].SessionID) - fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") + // Print per-session resume commands using returned sessions + if len(sessions) > 1 { + fmt.Fprintf(os.Stderr, "\nRestored %d sessions. To continue, run:\n", len(sessions)) + } else if len(sessions) == 1 { + fmt.Fprintf(os.Stderr, "Session: %s\n", sessions[0].SessionID) + fmt.Fprintf(os.Stderr, "\nTo continue this session, run:\n") + } + for i, sess := range sessions { + sessionAgent, err := strategy.ResolveAgentForRewind(sess.Agent) + if err != nil { + return fmt.Errorf("failed to resolve agent for session %s: %w", sess.SessionID, err) } - for i, sess := range sessions { - sessionAgent, err := strategy.ResolveAgentForRewind(sess.Agent) - if err != nil { - return fmt.Errorf("failed to resolve agent for session %s: %w", sess.SessionID, err) - } - cmd := sessionAgent.FormatResumeCommand(sess.SessionID) - - if len(sessions) > 1 { - if i == len(sessions)-1 { - if sess.Prompt != "" { - fmt.Fprintf(os.Stderr, " %s # %s (most recent)\n", cmd, sess.Prompt) - } else { - fmt.Fprintf(os.Stderr, " %s # (most recent)\n", cmd) - } + cmd := sessionAgent.FormatResumeCommand(sess.SessionID) + + if len(sessions) > 1 { + if i == len(sessions)-1 { + if sess.Prompt != "" { + fmt.Fprintf(os.Stderr, " %s # %s (most recent)\n", cmd, sess.Prompt) } else { - if sess.Prompt != "" { - fmt.Fprintf(os.Stderr, " %s # %s\n", cmd, sess.Prompt) - } else { - fmt.Fprintf(os.Stderr, " %s\n", cmd) - } + fmt.Fprintf(os.Stderr, " %s # (most recent)\n", cmd) } } else { if sess.Prompt != "" { @@ -467,13 +459,16 @@ func resumeSession(sessionID string, checkpointID id.CheckpointID, force bool) e fmt.Fprintf(os.Stderr, " %s\n", cmd) } } + } else { + if sess.Prompt != "" { + fmt.Fprintf(os.Stderr, " %s # %s\n", cmd, sess.Prompt) + } else { + fmt.Fprintf(os.Stderr, " %s\n", cmd) + } } - - return nil } - // Strategy doesn't support LogsOnlyRestorer, fall back to single session - return resumeSingleSession(ctx, ag, sessionID, checkpointID, repoRoot, force) + return nil } // resumeSingleSession restores a single session (fallback when multi-session restore fails). diff --git a/cmd/entire/cli/rewind.go b/cmd/entire/cli/rewind.go index aa59303b3..3836fc4cc 100644 --- a/cmd/entire/cli/rewind.go +++ b/cmd/entire/cli/rewind.go @@ -546,12 +546,7 @@ func handleLogsOnlyRewindNonInteractive(start strategy.Strategy, point strategy. slog.String("session_id", point.SessionID), ) - restorer, ok := start.(strategy.LogsOnlyRestorer) - if !ok { - return errors.New("strategy does not support logs-only restoration") - } - - sessions, err := restorer.RestoreLogsOnly(point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(point, true) // force=true for explicit rewind if err != nil { logging.Error(ctx, "logs-only rewind failed", slog.String("checkpoint_id", point.ID), @@ -589,11 +584,6 @@ func handleLogsOnlyResetNonInteractive(start strategy.Strategy, point strategy.R slog.String("session_id", point.SessionID), ) - restorer, ok := start.(strategy.LogsOnlyRestorer) - if !ok { - return errors.New("strategy does not support logs-only restoration") - } - // Get current HEAD before reset (for recovery message) currentHead, headErr := getCurrentHeadHash() if headErr != nil { @@ -601,7 +591,7 @@ func handleLogsOnlyResetNonInteractive(start strategy.Strategy, point strategy.R } // Restore logs first - sessions, err := restorer.RestoreLogsOnly(point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(point, true) // force=true for explicit rewind if err != nil { logging.Error(ctx, "logs-only reset failed during log restoration", slog.String("checkpoint_id", point.ID), @@ -838,14 +828,8 @@ func handleLogsOnlyRestore(start strategy.Strategy, point strategy.RewindPoint) slog.String("session_id", point.SessionID), ) - // Check if strategy supports logs-only restoration - restorer, ok := start.(strategy.LogsOnlyRestorer) - if !ok { - return errors.New("strategy does not support logs-only restoration") - } - // Restore logs - sessions, err := restorer.RestoreLogsOnly(point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(point, true) // force=true for explicit rewind if err != nil { logging.Error(ctx, "logs-only restore failed", slog.String("checkpoint_id", point.ID), @@ -881,13 +865,7 @@ func handleLogsOnlyCheckout(start strategy.Strategy, point strategy.RewindPoint, slog.String("session_id", point.SessionID), ) - // First, restore the logs - restorer, ok := start.(strategy.LogsOnlyRestorer) - if !ok { - return errors.New("strategy does not support logs-only restoration") - } - - sessions, err := restorer.RestoreLogsOnly(point, true) // force=true for explicit rewind + sessions, err := start.RestoreLogsOnly(point, true) // force=true for explicit rewind if err != nil { logging.Error(ctx, "logs-only checkout failed during log restoration", slog.String("checkpoint_id", point.ID), @@ -952,13 +930,7 @@ func handleLogsOnlyReset(start strategy.Strategy, point strategy.RewindPoint, sh slog.String("session_id", point.SessionID), ) - // First, restore the logs - restorer, ok := start.(strategy.LogsOnlyRestorer) - if !ok { - return errors.New("strategy does not support logs-only restoration") - } - - sessions, restoreErr := restorer.RestoreLogsOnly(point, true) // force=true for explicit rewind + sessions, restoreErr := start.RestoreLogsOnly(point, true) // force=true for explicit rewind if restoreErr != nil { logging.Error(ctx, "logs-only reset failed during log restoration", slog.String("checkpoint_id", point.ID), diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index c9e9243f8..336d32bbe 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -5,7 +5,6 @@ import ( "runtime" "github.com/entireio/cli/cmd/entire/cli/buildinfo" - "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/entireio/cli/cmd/entire/cli/telemetry" "github.com/entireio/cli/cmd/entire/cli/versioncheck" "github.com/spf13/cobra" @@ -59,7 +58,7 @@ func NewRootCmd() *cobra.Command { // Use detached tracking (non-blocking) installedAgents := GetAgentsWithHooksInstalled() agentStr := JoinAgentNames(installedAgents) - telemetry.TrackCommandDetached(cmd, strategy.StrategyNameManualCommit, agentStr, settings.Enabled, buildinfo.Version) + telemetry.TrackCommandDetached(cmd, agentStr, settings.Enabled, buildinfo.Version) } // Version check and notification (synchronous with 2s timeout) diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index 937b82804..a890d965c 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -340,39 +340,20 @@ func ListAllCleanupItems() ([]CleanupItem, error) { var items []CleanupItem var firstErr error - // Iterate over all registered strategies - for _, name := range List() { - strat, err := Get(name) - if err != nil { - if firstErr == nil { - firstErr = err - } - continue - } - - // Check if strategy implements OrphanedItemsLister - if lister, ok := strat.(OrphanedItemsLister); ok { - stratItems, err := lister.ListOrphanedItems() - if err != nil { - if firstErr == nil { - firstErr = err - } - continue - } - items = append(items, stratItems...) - } + strat := NewManualCommitStrategy() + stratItems, err := strat.ListOrphanedItems() + if err != nil { + return nil, fmt.Errorf("listing orphaned items: %w", err) } - + items = append(items, stratItems...) // Orphaned session states (strategy-agnostic) states, err := ListOrphanedSessionStates() if err != nil { - if firstErr == nil { - firstErr = err - } - } else { - items = append(items, states...) + return nil, err } + items = append(items, states...) + return items, firstErr } diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 6ee98aa55..efe6f85e3 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -121,8 +121,5 @@ func (s *ManualCommitStrategy) ListOrphanedItems() ([]CleanupItem, error) { return items, nil } -//nolint:gochecknoinits // Standard pattern for strategy registration -func init() { - // Register manual-commit as the primary strategy name - Register(StrategyNameManualCommit, NewManualCommitStrategy) -} +// Compile-time check that ManualCommitStrategy implements SessionSource +var _ Strategy = (*ManualCommitStrategy)(nil) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 3ae01d2bb..57b744397 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -121,7 +121,7 @@ func askConfirmTTY(prompt string, context string, defaultYes bool) bool { // If the message contains only our trailer (no actual user content), strip it // so git will abort the commit due to empty message. // -//nolint:unparam // error return required by interface but hooks must return nil + func (s *ManualCommitStrategy) CommitMsg(commitMsgFile string) error { content, err := os.ReadFile(commitMsgFile) //nolint:gosec // Path comes from git hook if err != nil { @@ -598,7 +598,7 @@ func (h *postCommitActionHandler) HandleWarnStaleSession(_ *session.State) error // During rebase/cherry-pick/revert operations, phase transitions are skipped entirely. // -//nolint:unparam // error return required by interface but hooks must return nil + func (s *ManualCommitStrategy) PostCommit() error { logCtx := logging.WithComponent(context.Background(), "checkpoint") @@ -1681,7 +1681,7 @@ func (s *ManualCommitStrategy) getLastPrompt(repo *git.Repository, state *Sessio // at commit time). HandleTurnEnd replaces that with the complete session transcript // (from prompt to stop event), ensuring every checkpoint has the full context. // -//nolint:unparam // error return required by interface but hooks must return nil + func (s *ManualCommitStrategy) HandleTurnEnd(state *SessionState) error { // Finalize all checkpoints from this turn with the full transcript. // diff --git a/cmd/entire/cli/strategy/manual_commit_logs.go b/cmd/entire/cli/strategy/manual_commit_logs.go index a2fef9229..8df0ec810 100644 --- a/cmd/entire/cli/strategy/manual_commit_logs.go +++ b/cmd/entire/cli/strategy/manual_commit_logs.go @@ -253,6 +253,3 @@ func (s *ManualCommitStrategy) getDescriptionFromShadowBranch(sessionID, baseCom metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) return getSessionDescriptionFromTree(tree, metadataDir) } - -// Compile-time check that ManualCommitStrategy implements SessionSource -var _ SessionSource = (*ManualCommitStrategy)(nil) diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 4dec81de9..4eb731302 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -21,35 +21,6 @@ import ( const testTrailerCheckpointID id.CheckpointID = "a1b2c3d4e5f6" -func TestShadowStrategy_Registration(t *testing.T) { - s, err := Get(StrategyNameManualCommit) - if err != nil { - t.Fatalf("Get(%q) error = %v", StrategyNameManualCommit, err) - } - if s == nil { - t.Fatal("Get() returned nil strategy") - } - if s.Name() != StrategyNameManualCommit { - t.Errorf("Name() = %q, want %q", s.Name(), StrategyNameManualCommit) - } -} - -func TestShadowStrategy_DirectInstantiation(t *testing.T) { - // NewShadowStrategy delegates to NewManualCommitStrategy, so returns manual-commit name. - s := NewManualCommitStrategy() - if s.Name() != StrategyNameManualCommit { - t.Errorf("Name() = %q, want %q", s.Name(), StrategyNameManualCommit) - } -} - -func TestShadowStrategy_Description(t *testing.T) { - s := NewManualCommitStrategy() - desc := s.Description() - if desc == "" { - t.Error("Description() returned empty string") - } -} - func TestShadowStrategy_ValidateRepository(t *testing.T) { dir := t.TempDir() _, err := git.PlainInit(dir, false) diff --git a/cmd/entire/cli/strategy/registry.go b/cmd/entire/cli/strategy/registry.go deleted file mode 100644 index 3ca8ada6a..000000000 --- a/cmd/entire/cli/strategy/registry.go +++ /dev/null @@ -1,52 +0,0 @@ -package strategy - -import ( - "fmt" - "sort" - "sync" -) - -var ( - registryMu sync.RWMutex - registry = make(map[string]Factory) -) - -// Factory creates a new strategy instance -type Factory func() Strategy - -// Register adds a strategy factory to the registry. -// This is typically called from init() functions in strategy implementations. -func Register(name string, factory Factory) { - registryMu.Lock() - defer registryMu.Unlock() - registry[name] = factory -} - -// Get retrieves a strategy by name. -// Returns an error if the strategy is not registered. -// - -func Get(name string) (Strategy, error) { - registryMu.RLock() - defer registryMu.RUnlock() - - factory, ok := registry[name] - if !ok { - return nil, fmt.Errorf("unknown strategy: %s (available: %v)", name, List()) - } - - return factory(), nil -} - -// List returns all registered strategy names in sorted order. -func List() []string { - registryMu.RLock() - defer registryMu.RUnlock() - - names := make([]string, 0, len(registry)) - for name := range registry { - names = append(names, name) - } - sort.Strings(names) - return names -} diff --git a/cmd/entire/cli/strategy/rewind_test.go b/cmd/entire/cli/strategy/rewind_test.go index d76b9ba2c..3dcf829aa 100644 --- a/cmd/entire/cli/strategy/rewind_test.go +++ b/cmd/entire/cli/strategy/rewind_test.go @@ -14,52 +14,6 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" ) -func TestRewindPointIsTaskCheckpoint(t *testing.T) { - // Test that RewindPoint has IsTaskCheckpoint and ToolUseID fields - - // Session checkpoint (default) - sessionPoint := RewindPoint{ - // Only checking IsTaskCheckpoint and ToolUseID fields - } - if sessionPoint.IsTaskCheckpoint { - t.Error("session checkpoint should have IsTaskCheckpoint=false by default") - } - if sessionPoint.ToolUseID != "" { - t.Error("session checkpoint should have empty ToolUseID by default") - } - - // Task checkpoint - taskPoint := RewindPoint{ - IsTaskCheckpoint: true, - ToolUseID: "toolu_abc123", - } - if !taskPoint.IsTaskCheckpoint { - t.Error("task checkpoint should have IsTaskCheckpoint=true") - } - if taskPoint.ToolUseID != "toolu_abc123" { - t.Errorf("task checkpoint should have ToolUseID='toolu_abc123', got %q", taskPoint.ToolUseID) - } -} - -func TestRewindPointExtractToolUseID(t *testing.T) { - // Test helper to extract ToolUseID from task metadata dir - tests := []struct { - metadataDir string - want string - }{ - {".entire/metadata/2025-01-28-session/tasks/toolu_abc123", "toolu_abc123"}, - {".entire/metadata/2025-01-28-session/tasks/toolu_xyz789", "toolu_xyz789"}, - {".entire/metadata/2025-01-28-session", ""}, - } - - for _, tt := range tests { - got := ExtractToolUseIDFromTaskMetadataDir(tt.metadataDir) - if got != tt.want { - t.Errorf("ExtractToolUseIDFromTaskMetadataDir(%q) = %q, want %q", tt.metadataDir, got, tt.want) - } - } -} - func TestShadowStrategy_PreviewRewind(t *testing.T) { dir := t.TempDir() repo, err := git.PlainInit(dir, false) diff --git a/cmd/entire/cli/strategy/session.go b/cmd/entire/cli/strategy/session.go index 074d878bc..5da2baecf 100644 --- a/cmd/entire/cli/strategy/session.go +++ b/cmd/entire/cli/strategy/session.go @@ -119,20 +119,9 @@ func ListSessions() ([]Session, error) { } } - // Check all registered strategies for additional sessions - for _, name := range List() { - strat, stratErr := Get(name) - if stratErr != nil { - continue - } - source, ok := strat.(SessionSource) - if !ok { - continue - } - additionalSessions, addErr := source.GetAdditionalSessions() - if addErr != nil { - continue // Skip strategies that fail to provide additional sessions - } + // Check for additional sessions + strat := NewManualCommitStrategy() + if additionalSessions, err := strat.GetAdditionalSessions(); err == nil { for _, addSession := range additionalSessions { if addSession == nil { continue diff --git a/cmd/entire/cli/strategy/session_test.go b/cmd/entire/cli/strategy/session_test.go index 31dc464ec..904854e9f 100644 --- a/cmd/entire/cli/strategy/session_test.go +++ b/cmd/entire/cli/strategy/session_test.go @@ -165,13 +165,9 @@ func TestEmptySession(t *testing.T) { func TestManualCommitStrategyImplementsSessionSource(t *testing.T) { // Manual-commit strategy should implement SessionSource var strat = NewManualCommitStrategy() - source, ok := strat.(SessionSource) - if !ok { - t.Fatal("ManualCommitStrategy should implement SessionSource interface") - } // GetAdditionalSessions should be callable - _, err := source.GetAdditionalSessions() + _, err := strat.GetAdditionalSessions() if err != nil { t.Logf("GetAdditionalSessions returned error: %v", err) } diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index e9c8dcb98..f7f89b8fe 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "os" - "strings" "time" "github.com/entireio/cli/cmd/entire/cli/agent" @@ -26,30 +25,6 @@ var ErrNotTaskCheckpoint = errors.New("not a task checkpoint") // ErrEmptyRepository is returned when the repository has no commits yet. var ErrEmptyRepository = errors.New("repository has no commits yet") -// SessionIDConflictError is returned when trying to start a new session -// but the shadow branch already has commits from a different session ID. -// This prevents orphaning existing session work. -type SessionIDConflictError struct { - ExistingSession string // Session ID found in the shadow branch - NewSession string // Session ID being initialized - ShadowBranch string // The shadow branch name (e.g., "entire/abc1234") -} - -func (e *SessionIDConflictError) Error() string { - return "session ID conflict: shadow branch has commits from a different session" -} - -// ExtractToolUseIDFromTaskMetadataDir extracts the ToolUseID from a task metadata directory path. -// Task metadata dirs have format: .entire/metadata//tasks/ -// Returns empty string if not a task metadata directory. -func ExtractToolUseIDFromTaskMetadataDir(metadataDir string) string { - parts := strings.Split(metadataDir, "/") - if len(parts) >= 2 && parts[len(parts)-2] == "tasks" { - return parts[len(parts)-1] - } - return "" -} - // SessionInfo contains information about the current session state. // This is used to generate trailers for linking commits to their AI session. type SessionInfo struct { @@ -316,9 +291,6 @@ type Strategy interface { // Name returns the strategy identifier (e.g., "commit", "branch", "stash") Name() string - // Description returns a human-readable description for the setup wizard - Description() string - // ValidateRepository checks if the repository is in a valid state // for this strategy to operate. Returns an error if validation fails. ValidateRepository() error @@ -389,24 +361,12 @@ type Strategy interface { // For strategies that store on disk (commit), reads from the filesystem. // Returns ErrNoMetadata if transcript is not available. GetCheckpointLog(checkpoint Checkpoint) ([]byte, error) -} - -// SessionInitializer is an optional interface for strategies that need to -// initialize session state when a user prompt is submitted. -// Strategies like manual-commit use this to create session state files that -// the git prepare-commit-msg hook can detect. -type SessionInitializer interface { // InitializeSession creates session state for a new session. // Called during UserPromptSubmit hook before any checkpoints are created. // agentType is the human-readable name of the agent (e.g., "Claude Code"). // transcriptPath is the path to the live transcript file (for mid-session commit detection). // userPrompt is the user's prompt text (stored truncated as FirstPrompt for display). InitializeSession(sessionID string, agentType agent.AgentType, transcriptPath string, userPrompt string) error -} - -// PrepareCommitMsgHandler is an optional interface for strategies that need to -// handle the git prepare-commit-msg hook. -type PrepareCommitMsgHandler interface { // PrepareCommitMsg is called by the git prepare-commit-msg hook. // It can modify the commit message file to add trailers, etc. // The source parameter indicates how the commit was initiated: @@ -417,128 +377,59 @@ type PrepareCommitMsgHandler interface { // - "commit": amend with -c/-C // Should return nil on errors to not block commits (log warnings to stderr). PrepareCommitMsg(commitMsgFile string, source string) error -} - -// PostCommitHandler is an optional interface for strategies that need to -// handle the git post-commit hook. -type PostCommitHandler interface { // PostCommit is called by the git post-commit hook after a commit is created. // Used to perform actions like condensing session data after commits. // Should return nil on errors to not block subsequent operations (log warnings to stderr). PostCommit() error -} - -// CommitMsgHandler is an optional interface for strategies that need to -// handle the git commit-msg hook. -type CommitMsgHandler interface { // CommitMsg is called by the git commit-msg hook after the user edits the message. // Used to validate or modify the final commit message before the commit is created. // If this returns an error, the commit is aborted. CommitMsg(commitMsgFile string) error -} - -// PrePushHandler is an optional interface for strategies that need to -// handle the git pre-push hook. -type PrePushHandler interface { // PrePush is called by the git pre-push hook before pushing to a remote. // Used to push session branches (e.g., entire/checkpoints/v1) alongside user pushes. // The remote parameter is the name of the remote being pushed to. // Should return nil on errors to not block pushes (log warnings to stderr). PrePush(remote string) error -} - -// TurnEndHandler is an optional interface for strategies that need to -// perform work when an agent turn ends (ACTIVE → IDLE). -// For example, manual-commit strategy uses this to finalize checkpoints -// with the full session transcript. -type TurnEndHandler interface { // HandleTurnEnd performs strategy-specific cleanup at the end of a turn. // Work items are read from state (e.g. TurnCheckpointIDs), not from the // action list. The state has already been updated by ApplyTransition; // the caller saves it after this method returns. HandleTurnEnd(state *session.State) error -} - -// RestoredSession describes a single session that was restored by RestoreLogsOnly. -// Each session may come from a different agent, so callers use this to print -// per-session resume commands without re-reading the metadata tree. -type RestoredSession struct { - SessionID string - Agent agent.AgentType - Prompt string - CreatedAt time.Time // From session metadata; used by resume to determine most recent -} - -// LogsOnlyRestorer is an optional interface for strategies that support -// restoring session logs without file state restoration. -// This is used for "logs-only" rewind points where only the session transcript -// can be restored (file state requires git checkout). -type LogsOnlyRestorer interface { // RestoreLogsOnly restores session logs from a logs-only rewind point. // Does not modify the working directory - only restores the transcript // to the agent's session directory (determined per-session from checkpoint metadata). // If force is false, prompts for confirmation when local logs have newer timestamps. // Returns info about each restored session so callers can print correct resume commands. RestoreLogsOnly(point RewindPoint, force bool) ([]RestoredSession, error) -} - -// SessionResetter is an optional interface for strategies that support -// resetting session state and shadow branches. -// This is used by the "reset" command to clean up shadow branches -// and session state when a user wants to start fresh. -type SessionResetter interface { // Reset deletes the shadow branch and session state for the current HEAD. // Returns nil if there's nothing to reset (no shadow branch). Reset() error - // ResetSession clears the state for a single session and cleans up // the shadow branch if no other sessions reference it. // File changes remain in the working directory. ResetSession(sessionID string) error -} - -// SessionCondenser is an optional interface for strategies that support -// force-condensing a session. This is used by "entire doctor" to -// salvage stuck sessions by condensing their data to permanent storage. -type SessionCondenser interface { // CondenseSessionByID force-condenses a session and cleans up. // Generates a new checkpoint ID, condenses to entire/checkpoints/v1, // updates the session state, and removes the shadow branch // if no other active sessions need it. CondenseSessionByID(sessionID string) error -} - -// ConcurrentSessionChecker is an optional interface for strategies that support -// counting concurrent sessions with uncommitted changes. -// This is used by the SessionStart hook to show an informational message about -// how many other active conversations will be included in the next commit. -type ConcurrentSessionChecker interface { // CountOtherActiveSessionsWithCheckpoints returns the number of other active sessions // with uncommitted checkpoints on the same base commit. // Returns 0, nil if no such sessions exist. CountOtherActiveSessionsWithCheckpoints(currentSessionID string) (int, error) -} - -// SessionSource is an optional interface for strategies that provide additional -// sessions beyond those stored on the entire/checkpoints/v1 branch. -// For example, manual-commit strategy provides active sessions from .git/entire-sessions/ -// that haven't yet been condensed to entire/checkpoints/v1. -// -// ListSessions() automatically discovers all registered strategies, checks if they -// implement SessionSource, and merges their additional sessions by ID. -type SessionSource interface { // GetAdditionalSessions returns sessions not yet on entire/checkpoints/v1 branch. GetAdditionalSessions() ([]*Session, error) -} - -// OrphanedItemsLister is an optional interface for strategies that can identify -// orphaned items (shadow branches, session states, checkpoints) that should be -// cleaned up. This is used by the "entire session cleanup" command. -// -// ListAllCleanupItems() automatically discovers all registered strategies, checks -// if they implement OrphanedItemsLister, and combines their orphaned items. -type OrphanedItemsLister interface { // ListOrphanedItems returns items created by this strategy that are now orphaned. // Each strategy defines what "orphaned" means for its own data structures. ListOrphanedItems() ([]CleanupItem, error) } + +// RestoredSession describes a single session that was restored by RestoreLogsOnly. +// Each session may come from a different agent, so callers use this to print +// per-session resume commands without re-reading the metadata tree. +type RestoredSession struct { + SessionID string + Agent agent.AgentType + Prompt string + CreatedAt time.Time // From session metadata; used by resume to determine most recent +} diff --git a/cmd/entire/cli/telemetry/detached.go b/cmd/entire/cli/telemetry/detached.go index ca7bf3f8d..303a1fef1 100644 --- a/cmd/entire/cli/telemetry/detached.go +++ b/cmd/entire/cli/telemetry/detached.go @@ -40,7 +40,7 @@ func (silentLogger) Errorf(_ string, _ ...interface{}) {} // BuildEventPayload constructs the event payload for tracking. // Exported for testing. Returns nil if the payload cannot be built. -func BuildEventPayload(cmd *cobra.Command, strategy, agent string, isEntireEnabled bool, version string) *EventPayload { +func BuildEventPayload(cmd *cobra.Command, agent string, isEntireEnabled bool, version string) *EventPayload { if cmd == nil { return nil } @@ -64,7 +64,6 @@ func BuildEventPayload(cmd *cobra.Command, strategy, agent string, isEntireEnabl properties := map[string]any{ "command": cmd.CommandPath(), - "strategy": strategy, "agent": selectedAgent, "isEntireEnabled": isEntireEnabled, "cli_version": version, @@ -86,7 +85,7 @@ func BuildEventPayload(cmd *cobra.Command, strategy, agent string, isEntireEnabl // TrackCommandDetached tracks a command execution by spawning a detached subprocess. // This returns immediately without blocking the CLI. -func TrackCommandDetached(cmd *cobra.Command, strategy, agent string, isEntireEnabled bool, version string) { +func TrackCommandDetached(cmd *cobra.Command, agent string, isEntireEnabled bool, version string) { // Check opt-out environment variables if os.Getenv("ENTIRE_TELEMETRY_OPTOUT") != "" { return @@ -100,7 +99,7 @@ func TrackCommandDetached(cmd *cobra.Command, strategy, agent string, isEntireEn return } - payload := BuildEventPayload(cmd, strategy, agent, isEntireEnabled, version) + payload := BuildEventPayload(cmd, agent, isEntireEnabled, version) if payload == nil { return } diff --git a/cmd/entire/cli/telemetry/detached_test.go b/cmd/entire/cli/telemetry/detached_test.go index a41f8c474..780646edb 100644 --- a/cmd/entire/cli/telemetry/detached_test.go +++ b/cmd/entire/cli/telemetry/detached_test.go @@ -55,7 +55,7 @@ func TestEventPayloadSerialization(t *testing.T) { func TestTrackCommandDetachedSkipsNilCommand(_ *testing.T) { // Should not panic with nil command - TrackCommandDetached(nil, "manual-commit", "claude-code", true, "1.0.0") + TrackCommandDetached(nil, "claude-code", true, "1.0.0") } func TestTrackCommandDetachedSkipsHiddenCommands(_ *testing.T) { @@ -65,7 +65,7 @@ func TestTrackCommandDetachedSkipsHiddenCommands(_ *testing.T) { } // Should not panic and should skip hidden commands - TrackCommandDetached(hiddenCmd, "manual-commit", "claude-code", true, "1.0.0") + TrackCommandDetached(hiddenCmd, "claude-code", true, "1.0.0") } func TestTrackCommandDetachedRespectsOptOut(t *testing.T) { @@ -76,7 +76,7 @@ func TestTrackCommandDetachedRespectsOptOut(t *testing.T) { } // Should not panic and should respect opt-out - TrackCommandDetached(cmd, "manual-commit", "claude-code", true, "1.0.0") + TrackCommandDetached(cmd, "claude-code", true, "1.0.0") } func TestBuildEventPayloadAgent(t *testing.T) { @@ -92,7 +92,7 @@ func TestBuildEventPayloadAgent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cmd := &cobra.Command{Use: "test"} - payload := BuildEventPayload(cmd, "manual-commit", tt.inputAgent, true, "1.0.0") + payload := BuildEventPayload(cmd, tt.inputAgent, true, "1.0.0") if payload == nil { t.Fatal("Expected non-nil payload") return From 4b74416295344d4dfa21802ed1ebe9266a42f398 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Wed, 25 Feb 2026 14:33:59 +1100 Subject: [PATCH 08/11] deletes all references to multi strategy CLAUDE.md --- CLAUDE.md | 112 +++++++++++++++++++++++++--------------- cmd/entire/cli/clean.go | 2 +- 2 files changed, 72 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bc79bacc6..e94d7505f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# Entire - CLI +# Entire - CLI This repo contains the CLI for Entire. @@ -9,11 +9,12 @@ This repo contains the CLI for Entire. ## Key Directories ### Commands (`cmd/`) + - `entire/`: Main CLI entry point - `entire/cli`: CLI utilities and helpers - `entire/cli/commands`: actual command implementations - `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md) -- `entire/cli/strategy`: strategy implementations - see section below +- `entire/cli/strategy`: strategy implementation (manual-commit) - see section below - `entire/cli/checkpoint`: checkpoint storage abstractions (temporary and committed) - `entire/cli/session`: session state management - `entire/cli/integration_test`: integration tests (simulated hooks) @@ -28,16 +29,19 @@ This repo contains the CLI for Entire. ## Development ### Running Tests + ```bash mise run test ``` ### Running Integration Tests + ```bash mise run test:integration ``` ### Running All Tests (CI) + ```bash mise run test:ci ``` @@ -57,6 +61,7 @@ E2E_AGENT=claude-code go test -tags=e2e -run TestE2E_BasicWorkflow ./cmd/entire/ ``` E2E tests: + - Use the `//go:build e2e` build tag - Located in `cmd/entire/cli/e2e_test/` - Test real agent interactions (Claude Code, Gemini CLI, or OpenCode creating files, committing, etc.) @@ -64,6 +69,7 @@ E2E tests: - Support multiple agents via `E2E_AGENT` env var (`claude-code`, `gemini`, `opencode`) **Environment variables:** + - `E2E_AGENT` - Agent to test with (default: `claude-code`) - `E2E_CLAUDE_MODEL` - Claude model to use (default: `haiku` for cost efficiency) - `E2E_TIMEOUT` - Timeout per prompt (default: `2m`) @@ -78,19 +84,18 @@ func TestFeature_Foo(t *testing.T) { // ... } -// Integration tests: RunForAllStrategies handles t.Parallel() for subtests internally, -// but the top-level test still needs it +// Integration tests with TestEnv func TestFeature_Bar(t *testing.T) { t.Parallel() - RunForAllStrategies(t, func(t *testing.T, env *TestEnv, strategyName string) { - // ... - }) + env := NewFeatureBranchEnv(t) + // ... } ``` **Exception:** Tests that modify process-global state cannot be parallelized. This includes `os.Chdir()`/`t.Chdir()` and `os.Setenv()`/`t.Setenv()` — Go's test framework will panic if these are used after `t.Parallel()`. ### Linting and Formatting + ```bash mise run fmt && mise run lint ``` @@ -108,6 +113,7 @@ mise run test:ci # Run all tests (unit + integration) Or combined: `mise run fmt && mise run lint && mise run test:ci` **Common CI failures from skipping this:** + - `gofmt` formatting differences → run `mise run fmt` - Lint errors → run `mise run lint` and fix issues - Test failures → run `mise run test` and fix @@ -117,6 +123,7 @@ Or combined: `mise run fmt && mise run lint && mise run test:ci` Before implementing Go code, use `/go:discover-related` to find existing utilities and patterns that might be reusable. **Check for duplication:** + ```bash mise run dup # Comprehensive check (threshold 50) with summary mise run dup:staged # Check only staged files @@ -125,10 +132,12 @@ mise run lint:full # All issues at threshold 75 ``` **Tiered thresholds:** + - **75 tokens** (lint/CI) - Blocks on serious duplication (~20+ lines) - **50 tokens** (dup) - Advisory, catches smaller patterns (~10+ lines) When duplication is found: + 1. Check if a helper already exists in `common.go` or nearby utility files 2. If not, consider extracting the duplicated logic to a shared helper 3. If duplication is intentional (e.g., test setup), add a `//nolint:dupl` comment with explanation @@ -140,6 +149,7 @@ When duplication is found: The CLI uses a specific pattern for error output to avoid duplication between Cobra and main.go. **How it works:** + - `root.go` sets `SilenceErrors: true` globally - Cobra never prints errors - `main.go` prints errors to stderr, unless the error is a `SilentError` - Commands return `NewSilentError(err)` when they've already printed a custom message @@ -165,6 +175,7 @@ return fmt.Errorf("unknown strategy: %s", name) ``` **Key files:** + - `errors.go` - Defines `SilentError` type and `NewSilentError()` constructor - `root.go` - Sets `SilenceErrors: true` on root command - `main.go` - Checks for `SilentError` before printing @@ -177,6 +188,7 @@ All settings access should go through the `settings` package (`cmd/entire/cli/se The `settings` package exists to avoid import cycles. The `cli` package imports `strategy`, so `strategy` cannot import `cli`. The `settings` package provides shared settings loading that both can use. **Usage:** + ```go import "github.com/entireio/cli/cmd/entire/cli/settings" @@ -196,11 +208,13 @@ if settings.IsSummarizeEnabled() { ``` **Do NOT:** + - Read `.entire/settings.json` or `.entire/settings.local.json` directly with `os.ReadFile` - Duplicate settings parsing logic in other packages - Create new settings helpers without adding them to the `settings` package **Key files:** + - `settings/settings.go` - `EntireSettings` struct, `Load()`, and helper methods - `config.go` - Higher-level config functions that use settings (for `cli` package consumers) @@ -224,6 +238,7 @@ We use github.com/go-git/go-git for most git operations, but with important exce go-git v5 has a bug where `worktree.Reset()` with `git.HardReset` and `worktree.Checkout()` incorrectly delete untracked directories even when they're listed in `.gitignore`. This would destroy `.entire/` and `.worktrees/` directories. Use the git CLI instead: + ```go // WRONG - go-git deletes ignored directories worktree.Reset(&git.ResetOptions{ @@ -273,32 +288,28 @@ relPath := paths.ToRelativePath("/repo/api/file.ts", repoRoot) // returns "api/ Test case in `state_test.go`: `TestFilterAndNormalizePaths_SiblingDirectories` documents this bug pattern. -### Session Strategies (`cmd/entire/cli/strategy/`) +### Session Strategy (`cmd/entire/cli/strategy/`) + +The CLI uses a manual-commit strategy for managing session data and checkpoints. The strategy implements the `Strategy` interface defined in `strategy.go`. + +#### Strategy Interface -The CLI uses a strategy pattern for managing session data and checkpoints. Each strategy implements the `Strategy` interface defined in `strategy.go`. +The `Strategy` interface provides: -#### Core Interface -All strategies implement: - `SaveStep()` - Save session step checkpoint (code + metadata) - `SaveTaskStep()` - Save subagent task step checkpoint - `GetRewindPoints()` / `Rewind()` - List and restore to checkpoints - `GetSessionLog()` / `GetSessionInfo()` - Retrieve session data - `ListSessions()` / `GetSession()` - Session discovery -#### Available Strategies +#### How It Works -| Strategy | Main Branch | Metadata Storage | Use Case | -|----------|-------------|------------------|----------| -| **manual-commit** (default) | Unchanged (no commits) | `entire/-` branches + `entire/checkpoints/v1` | Session management without modifying active branch | +The manual-commit strategy (`manual_commit*.go`) does not modify the active branch - no commits are created on the working branch. Instead it: -#### Strategy Details - -**Manual-Commit Strategy** (`manual_commit*.go`) - Default -- **Does not modify** the active branch - no commits created on the working branch - Creates shadow branch `entire/-` per base commit + worktree - **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 -- Session logs are condensed to permanent `entire/checkpoints/v1` branch on user commits +- Condenses session logs to permanent `entire/checkpoints/v1` branch on user commits - 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. @@ -306,15 +317,14 @@ All strategies implement: - **Shadow branch migration** - if user does stash/pull/rebase (HEAD changes without commit), shadow branch is automatically moved to new base commit - **Orphaned branch cleanup** - if a shadow branch exists without a corresponding session state file, it is automatically reset when a new session starts - PrePush hook can push `entire/checkpoints/v1` branch alongside user pushes -- `AllowsMainBranch() = true` - safe to use on main/master since it never modifies commit history +- Safe to use on main/master since it never modifies commit history #### Key Files - `strategy.go` - Interface definition and context structs (`StepContext`, `TaskStepContext`, `RewindPoint`, etc.) -- `registry.go` - Strategy registration/discovery (factory pattern with `Get()`, `List()`, `Default()`) -- `common.go` - Shared helpers for metadata extraction, tree building, rewind validation, `ListCheckpoints()` +- `common.go` - Helpers for metadata extraction, tree building, rewind validation, `ListCheckpoints()` - `session.go` - Session/checkpoint data structures -- `push_common.go` - Shared PrePush logic for pushing `entire/checkpoints/v1` branch +- `push_common.go` - PrePush logic for pushing `entire/checkpoints/v1` branch - `manual_commit.go` - Manual-commit strategy main implementation - `manual_commit_types.go` - Type definitions: `SessionState`, `CheckpointInfo`, `CondenseResult` - `manual_commit_session.go` - Session state management (load/save/list session states) @@ -328,12 +338,14 @@ All strategies implement: - `hooks.go` - Git hook installation #### Checkpoint Package (`cmd/entire/cli/checkpoint/`) + - `checkpoint.go` - Data types (`Checkpoint`, `TemporaryCheckpoint`, `CommittedCheckpoint`) - `store.go` - `GitStore` struct wrapping git repository - `temporary.go` - Shadow branch operations (`WriteTemporary`, `ReadTemporary`, `ListTemporary`) - `committed.go` - Metadata branch operations (`WriteCommitted`, `ReadCommitted`, `ListCommitted`) #### Session Package (`cmd/entire/cli/session/`) + - `session.go` - Session data types and interfaces - `state.go` - `StateStore` for managing `.git/entire-sessions/` files - `phase.go` - Session phase state machine (phases, events, transitions, actions) @@ -345,6 +357,7 @@ Sessions track their lifecycle through phases managed by a state machine in `ses **Phases:** `ACTIVE`, `IDLE`, `ENDED` **Events:** + - `TurnStart` - Agent begins a turn (UserPromptSubmit hook) - `TurnEnd` - Agent finishes a turn (Stop hook) - `GitCommit` - A git commit was made (PostCommit hook) @@ -352,6 +365,7 @@ Sessions track their lifecycle through phases managed by a state machine in `ses - `SessionStop` - Session explicitly stopped **Key transitions:** + - `IDLE + TurnStart → ACTIVE` - Agent starts working - `ACTIVE + TurnEnd → IDLE` - Agent finishes turn - `ACTIVE + GitCommit → ACTIVE` - User commits while agent is working (condense immediately) @@ -362,7 +376,8 @@ The state machine emits **actions** (e.g., `ActionCondense`, `ActionUpdateLastIn #### Metadata Structure -**Shadow Strategy** - Shadow branches (`entire/-`): +**Shadow branches** (`entire/-`): + ``` .entire/metadata// ├── full.jsonl # Session transcript @@ -373,7 +388,8 @@ The state machine emits **actions** (e.g., `ActionCondense`, `ActionUpdateLastIn └── agent-.jsonl # Subagent transcript ``` -**Both Strategies** - Metadata branch (`entire/checkpoints/v1`) - sharded checkpoint format: +**Metadata branch** (`entire/checkpoints/v1`) - sharded checkpoint format: + ``` // ├── metadata.json # CheckpointSummary (aggregated stats) @@ -394,31 +410,34 @@ The state machine emits **actions** (e.g., `ActionCondense`, `ActionUpdateLastIn ``` **Multi-session metadata.json format:** + ```json { "checkpoint_id": "abc123def456", - "session_id": "2026-01-13-uuid", // Current/latest session - "session_ids": ["2026-01-13-uuid1", "2026-01-13-uuid2"], // All sessions - "session_count": 2, // Number of sessions in this checkpoint + "session_id": "2026-01-13-uuid", // Current/latest session + "session_ids": ["2026-01-13-uuid1", "2026-01-13-uuid2"], // All sessions + "session_count": 2, // Number of sessions in this checkpoint "strategy": "manual-commit", "created_at": "2026-01-13T12:00:00Z", - "files_touched": ["file1.txt", "file2.txt"] // Merged from all sessions + "files_touched": ["file1.txt", "file2.txt"] // Merged from all sessions } ``` When multiple sessions are condensed to the same checkpoint (same base commit): + - Sessions are stored in numbered subfolders using 0-based indexing (`0/`, `1/`, `2/`, etc.) - Latest session is always in the highest-numbered folder - `session_ids` array tracks all sessions, `session_count` increments **Session State** (filesystem, `.git/entire-sessions/`): + ``` .json # Active session state (base_commit, checkpoint_count, etc.) ``` #### Checkpoint ID Linking -Both strategies use a **12-hex-char random checkpoint ID** (e.g., `a3b2c4d5e6f7`) as the stable identifier linking user commits to metadata. +The strategy uses a **12-hex-char random checkpoint ID** (e.g., `a3b2c4d5e6f7`) as the stable identifier linking user commits to metadata. **How checkpoint IDs work:** @@ -452,6 +471,7 @@ Note: Commit subjects on `entire/checkpoints/v1` (e.g., `Checkpoint: a3b2c4d5e6f for human readability in `git log` only. The CLI always reads from the tree at HEAD. **Example:** + ``` User's commit (on main branch): "Implement login feature @@ -472,33 +492,36 @@ entire/checkpoints/v1 commit: #### Commit Trailers -**On user's active branch commits (both strategies):** +**On user's active branch commits:** + - `Entire-Checkpoint: ` - 12-hex-char ID linking to metadata on `entire/checkpoints/v1` - - Auto-commit: Always added when creating commits - - Manual-commit: Added by hook; user can remove to skip linking + - Added via `prepare-commit-msg` hook; user can remove it before committing to skip linking + +**On shadow branch commits (`entire/-`):** -**On shadow branch commits (`entire/-`) - manual-commit only:** - `Entire-Session: ` - Session identifier - `Entire-Metadata: ` - Path to metadata directory within the tree - `Entire-Task-Metadata: ` - Path to task metadata directory (for task checkpoints) - `Entire-Strategy: manual-commit` - Strategy that created the commit -**On metadata branch commits (`entire/checkpoints/v1`) - both strategies:** +**On metadata branch commits (`entire/checkpoints/v1`):** Commit subject: `Checkpoint: ` (or custom subject for task checkpoints) Trailers: + - `Entire-Session: ` - Session identifier - `Entire-Strategy: ` - Strategy name (manual-commit) - `Entire-Agent: ` - Agent name (optional, e.g., "Claude Code") - `Ephemeral-branch: ` - Shadow branch name (optional) - `Entire-Metadata-Task: ` - Task metadata path (optional, for task checkpoints) -**Note:** Manual-commit keeps active branch history clean - the only addition to user commits is the single `Entire-Checkpoint` trailer. Manual-commit never creates commits on the active branch (user creates them manually). All detailed session data (transcripts, prompts, context) is stored on the `entire/checkpoints/v1` orphan branch or shadow branches. +**Note:** The strategy keeps active branch history clean - the only addition to user commits is the single `Entire-Checkpoint` trailer. It never creates commits on the active branch (the user creates them manually). All detailed session data (transcripts, prompts, context) is stored on the `entire/checkpoints/v1` orphan branch or shadow branches. #### Multi-Session Behavior **Concurrent Sessions:** + - When a second session starts in the same directory while another has uncommitted checkpoints, a warning is shown - Both sessions can proceed - their checkpoints interleave on the same shadow branch - Each session's `RewindPoint` includes `SessionID` and `SessionPrompt` to help identify which checkpoint belongs to which session @@ -506,23 +529,25 @@ Trailers: - Note: Different git worktrees have separate shadow branches (worktree-specific naming), so concurrent sessions in different worktrees do not conflict **Orphaned Shadow Branches:** + - A shadow branch is "orphaned" if it exists but has no corresponding session state file - This can happen if the state file is manually deleted or lost - When a new session starts with an orphaned branch, the branch is automatically reset - If the existing session DOES have a state file (concurrent session in same directory), a `SessionIDConflictError` is returned **Shadow Branch Migration (Pull/Rebase):** + - If user does stash → pull → apply (or rebase), HEAD changes but work isn't committed - The shadow branch would be orphaned at the old commit - Detection: base commit changed AND old shadow branch still exists (would be deleted if user committed) - Action: shadow branch is renamed from `entire/-` to `entire/-` - Session continues seamlessly with checkpoints preserved -#### When Modifying Strategies -- All strategies must implement the full `Strategy` interface -- Register new strategies in `init()` using `Register()` +#### When Modifying the Strategy + +- The strategy must implement the full `Strategy` interface - Test with `mise run test` - strategy tests are in `*_test.go` files -- **Update both CLAUDE.md and AGENTS.md** when adding or modifying strategies to keep documentation current +- **Update both CLAUDE.md and AGENTS.md** when modifying the strategy to keep documentation current # Important Notes @@ -532,6 +557,7 @@ Trailers: - Always check for code duplication and refactor as needed. ## Go Code Style + - Write lint-compliant Go code on the first attempt. Before outputting Go code, mentally verify it passes `golangci-lint` (or your specific linter). - Follow standard Go idioms: proper error handling, no unused variables/imports, correct formatting (gofmt), meaningful names. - Handle all errors explicitly—don't leave them unchecked. @@ -542,6 +568,7 @@ Trailers: The CLI supports an accessibility mode for users who rely on screen readers. This mode uses simpler text prompts instead of interactive TUI elements. ### Environment Variable + - `ACCESSIBLE=1` (or any non-empty value) enables accessibility mode - Users can set this in their shell profile (`.bashrc`, `.zshrc`) for persistent use @@ -551,6 +578,7 @@ When adding new interactive forms or prompts using `huh`: **In the `cli` package:** Use `NewAccessibleForm()` instead of `huh.NewForm()`: + ```go // Good - respects ACCESSIBLE env var form := NewAccessibleForm( @@ -568,6 +596,7 @@ form := huh.NewForm(...) **In the `strategy` package:** Use the `isAccessibleMode()` helper. Note that `WithAccessible()` is only available on forms, not individual fields, so wrap confirmations in a form: + ```go form := huh.NewForm( huh.NewGroup( @@ -583,6 +612,7 @@ if err := form.Run(); err != nil { ... } ``` ### Key Points + - Always use the accessibility helpers for any `huh` forms/prompts - Test new interactive features with `ACCESSIBLE=1` to ensure they work - The accessible mode is documented in `--help` output diff --git a/cmd/entire/cli/clean.go b/cmd/entire/cli/clean.go index 15ae93499..220d30b6f 100644 --- a/cmd/entire/cli/clean.go +++ b/cmd/entire/cli/clean.go @@ -24,7 +24,7 @@ func newCleanCmd() *cobra.Command { This command finds and removes orphaned data from any strategy: Shadow branches (entire/) - Created by manual-commit strategy. Normally auto-cleaned when sessions + Normally auto-cleaned when sessions are condensed during commits. Session state files (.git/entire-sessions/) From c6acfa7b9c198be1d34f11c20966f75a6d364e52 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Wed, 25 Feb 2026 14:39:27 +1100 Subject: [PATCH 09/11] deletes all references to multi strategy README.md Entire-Checkpoint: b6f7279f2dc5 --- README.md | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index aafd30c9e..82a16b93d 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ With Entire, you can: - [Typical Workflow](#typical-workflow) - [Key Concepts](#key-concepts) - [How It Works](#how-it-works) - - [Strategies](#strategies) + - [Strategy](#strategy) - [Commands Reference](#commands-reference) - [Configuration](#configuration) - [Security & Privacy](#security--privacy) @@ -58,12 +58,7 @@ entire enable This installs agent and git hooks to work with your AI agent (Claude Code, Gemini CLI or OpenCode). You'll be prompted to select which agents to enable. To enable a specific agent non-interactively, use `entire enable --agent ` (e.g., `entire enable --agent opencode`). -The hooks capture session data at specific points in your workflow. Your code commits stay clean—all session metadata is stored on a separate `entire/checkpoints/v1` branch. - -**When checkpoints are created** depends on your chosen strategy (default is `manual-commit`): - -- **Manual-commit**: Checkpoints are created when you or the agent make a git commit -- **Auto-commit**: Checkpoints are created after each agent response +The hooks capture session data as you work. Checkpoints are created when you or the agent make a git commit. Your code commits stay clean, Entire never creates commits on your active branch. All session metadata is stored on a separate `entire/checkpoints/v1` branch. ### 2. Work with Your AI Agent @@ -115,12 +110,7 @@ Sessions are stored separately from your code commits on the `entire/checkpoints A **checkpoint** is a snapshot within a session that you can rewind to—a "save point" in your work. -**When checkpoints are created:** - -- **Manual-commit strategy**: When you or the agent make a git commit -- **Auto-commit strategy**: After each agent response - -**Checkpoint IDs** are 12-character hex strings (e.g., `a3b2c4d5e6f7`). +Checkpoints are created when you or the agent make a git commit. **Checkpoint IDs** are 12-character hex strings (e.g., `a3b2c4d5e6f7`). ### How It Works @@ -145,16 +135,14 @@ Your Branch entire/checkpoints/v1 Checkpoints are saved as you work. When you commit, session metadata is permanently stored on the `entire/checkpoints/v1` branch and linked to your commit. -### Strategies +### Strategy -Entire offers two strategies for capturing your work: +Entire uses a manual-commit strategy that keeps your git history clean: -| Aspect | Manual-Commit | Auto-Commit | -| ------------------- | ---------------------------------------- | -------------------------------------------------- | -| Code commits | None on your branch | Created automatically after each agent response | -| Safe on main branch | Yes | Use caution - creates commits on active branch | -| Rewind | Always possible, non-destructive | Full rewind on feature branches; logs-only on main | -| Best for | Most workflows - keeps git history clean | Teams wanting automatic code commits | +- **No commits on your branch** — Entire never creates commits on the active branch +- **Safe on any branch** — works on main, master, and feature branches alike +- **Non-destructive rewind** — restore files from any checkpoint without altering commit history +- **Metadata stored separately** — all session data lives on the `entire/checkpoints/v1` branch ### Git Worktrees @@ -171,12 +159,12 @@ Multiple AI sessions can run on the same commit. If you start a second session w | `entire clean` | Clean up orphaned Entire data | | `entire disable` | Remove Entire hooks from repository | | `entire doctor` | Fix or clean up stuck sessions | -| `entire enable` | Enable Entire in your repository (uses `manual-commit` by default) | +| `entire enable` | Enable Entire in your repository | | `entire explain` | Explain a session or commit | | `entire reset` | Delete the shadow branch and session state for the current HEAD commit | | `entire resume` | Switch to a branch, restore latest checkpointed session metadata, and show command(s) to continue | | `entire rewind` | Rewind to a previous checkpoint | -| `entire status` | Show current session and strategy info | +| `entire status` | Show current session info | | `entire version` | Show Entire CLI version | ### `entire enable` Flags From 26dfa08bada3f820a93fbc4aef4093473baf354e Mon Sep 17 00:00:00 2001 From: Alex Ong Date: Wed, 25 Feb 2026 15:03:52 +1100 Subject: [PATCH 10/11] clean up remaining auto-commit references - Remove single-option strategy dropdown from bug report template - Remove deprecated strategy field from README config docs - Delete unused NewShadowStrategy() legacy alias Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: 71c3d12348f0 --- .github/ISSUE_TEMPLATE/bug_report.yml | 10 ---------- README.md | 2 -- cmd/entire/cli/strategy/manual_commit.go | 8 -------- 3 files changed, 20 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index eba2bf4a8..c9054eef3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -60,16 +60,6 @@ body: validations: required: true - - type: dropdown - id: strategy - attributes: - label: Strategy - description: "Which strategy is configured? (check `.entire/settings.json` or `entire status`)" - options: - - manual-commit (default) - validations: - required: true - - type: input id: terminal attributes: diff --git a/README.md b/README.md index 82a16b93d..848ab2546 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,6 @@ Shared across the team, typically committed to git: ```json { - "strategy": "manual-commit", "enabled": true } ``` @@ -220,7 +219,6 @@ Personal overrides, gitignored by default: | ------------------------------------ | -------------------------------- | ---------------------------------------------------- | | `enabled` | `true`, `false` | Enable/disable Entire | | `log_level` | `debug`, `info`, `warn`, `error` | Logging verbosity | -| `strategy` | `manual-commit` | Session capture strategy | | `strategy_options.push_sessions` | `true`, `false` | Auto-push `entire/checkpoints/v1` branch on git push | | `strategy_options.summarize.enabled` | `true`, `false` | Auto-generate AI summaries at commit time | | `telemetry` | `true`, `false` | Send anonymous usage statistics to Posthog | diff --git a/cmd/entire/cli/strategy/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index efe6f85e3..451af92e8 100644 --- a/cmd/entire/cli/strategy/manual_commit.go +++ b/cmd/entire/cli/strategy/manual_commit.go @@ -62,14 +62,6 @@ func NewManualCommitStrategy() Strategy { return &ManualCommitStrategy{} } -// NewShadowStrategy creates a new manual-commit strategy instance. -// This legacy constructor delegates to NewManualCommitStrategy. -// - -func NewShadowStrategy() Strategy { - return NewManualCommitStrategy() -} - // Name returns the strategy name. func (s *ManualCommitStrategy) Name() string { return StrategyNameManualCommit From 02aff0090558a24671d7178c7fb0a34798dcb5dd Mon Sep 17 00:00:00 2001 From: Alex Ong Date: Wed, 25 Feb 2026 15:08:13 +1100 Subject: [PATCH 11/11] remove remaining auto-commit references from docs and comments - Update sessions-and-checkpoints.md: remove auto-commit generation/usage docs and strategy table row - Fix reviewer agent docs (.claude, .gemini): replace "auto-commits" with accurate checkpoint description - Fix status.go comments: remove auto-commit from format examples - Fix strategy.go comment: remove multi-strategy SaveTaskStep description Co-Authored-By: Claude Opus 4.6 Entire-Checkpoint: f9ef64998d14 --- .claude/agents/reviewer.md | 2 +- .gemini/agents/reviewer.md | 2 +- cmd/entire/cli/status.go | 4 ++-- cmd/entire/cli/strategy/strategy.go | 5 +---- docs/architecture/sessions-and-checkpoints.md | 18 ++++++++---------- 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 462a2b13f..bc356e065 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -19,7 +19,7 @@ You are a **Senior Code Reviewer** with decades of experience across multiple la - `.entire/` - conversation history - `docs/requirements/*/task-*.md` - task tracking files -**Why branch-scoped?** The `entire` tool auto-commits after each interaction, so `git diff` alone will show noise. Comparing against the base branch shows the actual feature work. +**Why branch-scoped?** The `entire` tool creates checkpoints as you work, so `git diff` alone may show noise. Comparing against the base branch shows the actual feature work. ## Review Philosophy diff --git a/.gemini/agents/reviewer.md b/.gemini/agents/reviewer.md index 462a2b13f..bc356e065 100644 --- a/.gemini/agents/reviewer.md +++ b/.gemini/agents/reviewer.md @@ -19,7 +19,7 @@ You are a **Senior Code Reviewer** with decades of experience across multiple la - `.entire/` - conversation history - `docs/requirements/*/task-*.md` - task tracking files -**Why branch-scoped?** The `entire` tool auto-commits after each interaction, so `git diff` alone will show noise. Comparing against the base branch shows the actual feature work. +**Why branch-scoped?** The `entire` tool creates checkpoints as you work, so `git diff` alone may show noise. Comparing against the base branch shows the actual feature work. ## Review Philosophy diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index b98564fe9..3628528de 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -135,7 +135,7 @@ func runStatusDetailed(w io.Writer, sty statusStyles, settingsPath, localSetting } // formatSettingsStatusShort formats a short settings status line. -// Output format: "● Enabled · manual-commit · branch main" or "○ Disabled · auto-commit" +// Output format: "● Enabled · manual-commit · branch main" or "○ Disabled" func formatSettingsStatusShort(s *EntireSettings, sty statusStyles) string { displayName := strategy.StrategyNameManualCommit @@ -167,7 +167,7 @@ func formatSettingsStatusShort(s *EntireSettings, sty statusStyles) string { } // formatSettingsStatus formats a settings status line with source prefix. -// Output format: "Project · enabled · manual-commit" or "Local · disabled · auto-commit" +// Output format: "Project · enabled · manual-commit" or "Local · disabled" func formatSettingsStatus(prefix string, s *EntireSettings, sty statusStyles) string { displayName := strategy.StrategyNameManualCommit diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index f7f89b8fe..8d00fc1c9 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -301,10 +301,7 @@ type Strategy interface { // SaveTaskStep is called by PostToolUse[Task] hook when a subagent completes. // Creates a checkpoint commit with task metadata for later rewind. - // Different strategies may handle this differently: - // - Commit strategy: commits to active branch - // - Manual-commit strategy: commits to shadow branch - // - Auto-commit strategy: commits logs to shadow only (code deferred to Stop) + // Commits to shadow branch for later condensation. SaveTaskStep(ctx TaskStepContext) error // GetRewindPoints returns available points to rewind to. diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index c260d6d67..ffda9152a 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -248,15 +248,13 @@ The checkpoint ID is the **stable identifier** that links user commits to metada **Format:** 12-hex-character random ID (e.g., `a3b2c4d5e6f7`) **Generation:** -- Manual-commit: Generated during condensation (post-commit hook) -- Auto-commit: Generated when creating the commit +- Generated during condensation (post-commit hook) **Usage:** -1. **User commit trailer** (both strategies): +1. **User commit trailer**: - `Entire-Checkpoint: a3b2c4d5e6f7` added to user's commit message - - Auto-commit: Added programmatically - - Manual-commit: Added by `prepare-commit-msg` hook (user can remove) + - Added by `prepare-commit-msg` hook (user can remove) 2. **Directory sharding** on `entire/checkpoints/v1`: - Path: `//` (e.g., `a3/b2c4d5e6f7/`) @@ -346,10 +344,11 @@ Strategies use `checkpoint.Store` primitives - storage details are encapsulated. Strategies determine checkpoint timing and type: -| Strategy | On Save | On Task Complete | On User Commit | -|----------|---------|------------------|----------------| -| Manual-commit | Temporary | Temporary | Condense → Committed | -| Auto-commit | Committed | Committed | — | +| Event | Checkpoint Type | +|-------|----------------| +| On Save | Temporary | +| On Task Complete | Temporary | +| On User Commit | Condense → Committed | ## Rewind @@ -386,4 +385,3 @@ If user does stash → pull → apply (HEAD changes without commit): | Current | Legacy | |---------|--------| | Manual-commit | Shadow | -| Auto-commit | Dual |