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 c9f62c500..4451b0980 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/GEMINI.md b/GEMINI.md index cab18a23a..d81804970 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -159,10 +159,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 | - -Legacy names `shadow` and `dual` are only recognized when reading settings or checkpoint metadata. +| **manual-commit** (default) | Unchanged (no commits) | `entire/` branches + `entire/checkpoints/v1` | Session management without modifying active branch | #### Strategy Details @@ -176,16 +173,6 @@ Legacy names `shadow` and `dual` are only recognized when reading settings or ch - 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() = false` - creates commits, so not recommended on main branch - #### Key Files - `strategy.go` - Interface definition and context structs (`StepContext`, `TaskStepContext`, `RewindPoint`, etc.) @@ -202,7 +189,6 @@ Legacy names `shadow` and `dual` are only recognized when reading settings or ch - `manual_commit_logs.go` - Session log retrieval and session listing - `manual_commit_hooks.go` - Git hook handlers (prepare-commit-msg, pre-push) - `manual_commit_reset.go` - Shadow branch reset/cleanup functionality -- `auto_commit.go` - Auto-commit strategy implementation - `hooks.go` - Git hook installation #### Checkpoint Package (`cmd/entire/cli/checkpoint/`) @@ -248,7 +234,7 @@ Legacy names `shadow` and `dual` are only recognized when reading settings or ch #### Commit Trailers -**On active branch commits (auto-commit strategy only):** +**On active branch commits:** - `Entire-Checkpoint: ` - 12-hex-char ID linking to metadata on `entire/checkpoints/v1` **On shadow branch commits (`entire/`):** diff --git a/README.md b/README.md index e2a6a0fb1..de1e7ef9a 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 @@ -59,6 +59,7 @@ entire enable This installs agent and git hooks to work with your AI agent (Claude Code or Gemini CLI). 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 @@ -129,7 +130,7 @@ Your Branch entire/checkpoints/v1 │ │ │ ┌─── Agent works ───┐ │ │ │ Step 1 │ │ - │ │ Step 2 │ │ + │ │ Step 2 │ │ │ │ Step 3 │ │ │ └───────────────────┘ │ │ │ @@ -163,37 +164,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 | -|------------------------|--------------------------------------------------------------------| +| ---------------------- | ------------------------------------------------------------------ | | `--agent ` | AI agent to install hooks for: `claude-code` (default) or `gemini` | | `--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 | **Examples:** ``` -# Use auto-commit strategy -entire enable --strategy auto-commit - # Force reinstall hooks entire enable --force @@ -231,10 +228,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 | @@ -254,6 +251,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 @@ -287,12 +285,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 15da60f9e..3cb1632ab 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 4bc44a887..d94cb4ba9 100644 --- a/cmd/entire/cli/clean.go +++ b/cmd/entire/cli/clean.go @@ -28,9 +28,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. Default: shows a preview of items that would be deleted. 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..f729ec343 100644 --- a/cmd/entire/cli/config_test.go +++ b/cmd/entire/cli/config_test.go @@ -5,14 +5,11 @@ import ( "path/filepath" "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 +31,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 +79,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) @@ -131,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) } @@ -165,7 +161,7 @@ func TestLoadEntireSettings_LocalOverridesStrategy(t *testing.T) { t.Fatalf("Failed to write settings file: %v", err) } - localSettings := `{"strategy": "` + strategy.StrategyNameAutoCommit + `"}` + localSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -174,9 +170,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 +195,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 +222,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 +252,7 @@ func TestLoadEntireSettings_OnlyLocalFileExists(t *testing.T) { setupLocalOverrideTestDir(t) // No base settings file - localSettings := `{"strategy": "auto-commit"}` + localSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -271,64 +261,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 +269,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 +286,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 index f7f08e196..3b4dffbfb 100644 --- a/cmd/entire/cli/debug.go +++ b/cmd/entire/cli/debug.go @@ -1,19 +1,6 @@ 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" -) +import "github.com/spf13/cobra" func newDebugCmd() *cobra.Command { cmd := &cobra.Command{ @@ -25,353 +12,5 @@ func newDebugCmd() *cobra.Command { }, } - 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.RepoRoot() - 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/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/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 0b9280d86..5d6f36747 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 aaed018b0..df3f207ba 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) @@ -79,7 +79,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, @@ -93,13 +93,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..6459a02e1 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() @@ -50,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() @@ -93,7 +89,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() @@ -112,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 @@ -130,7 +129,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 +168,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 +187,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 +219,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() @@ -239,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 @@ -284,7 +286,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() @@ -320,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 @@ -331,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) } @@ -348,7 +353,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() @@ -365,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") @@ -404,7 +412,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() @@ -423,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() @@ -580,7 +591,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() @@ -599,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 @@ -638,7 +652,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() @@ -657,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 @@ -697,7 +714,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() @@ -716,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 @@ -761,7 +781,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() @@ -780,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 @@ -826,7 +849,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() @@ -845,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 @@ -992,7 +1018,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() @@ -1011,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/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/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/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 a33bdc9c8..5eba03143 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, } } @@ -332,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 { @@ -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..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) { - // Only test auto-commit strategy - it creates commits on the working branch - worktreeStrategies := []string{ - strategy.StrategyNameAutoCommit, - } - - 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/lifecycle.go b/cmd/entire/cli/lifecycle.go index fe924f124..217bb8ac0 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 { @@ -230,7 +229,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/ @@ -258,17 +256,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 } } } @@ -409,11 +402,6 @@ 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 transitionSessionTurnEnd(sessionID) if cleanupErr := CleanupPrePromptState(sessionID); cleanupErr != nil { @@ -632,7 +620,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) @@ -646,29 +634,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 641e420bc..6c26e49ad 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -34,7 +34,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 strategy 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 f48b1215a..74a1f8e9c 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 1c9c1ad7f..500a2201a 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 9bd0f92f7..17070c4dc 100644 --- a/cmd/entire/cli/resume_test.go +++ b/cmd/entire/cli/resume_test.go @@ -179,74 +179,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) @@ -331,8 +263,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..c5662eabd 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) diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 381c9993a..3c49b731e 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" @@ -14,10 +15,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 +24,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. @@ -49,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, @@ -85,8 +84,6 @@ func Load() (*EntireSettings, error) { } } - applyDefaults(settings) - return settings, nil } @@ -101,8 +98,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 +114,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 +135,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 +191,6 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return nil } -func applyDefaults(settings *EntireSettings) { - if settings.Strategy == "" { - settings.Strategy = DefaultStrategyName - } -} - // IsSummarizeEnabled checks if auto-summarize is enabled in settings. // Returns false by default if settings cannot be loaded or the key is missing. func IsSummarizeEnabled() bool { @@ -255,6 +233,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. 'manual-commit' is now the only supported strategy.\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 ad09bc57a..9d4cf891a 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) } @@ -135,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/setup.go b/cmd/entire/cli/setup.go index c8be0541a..8d36897b3 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -19,37 +19,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 @@ -59,11 +40,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 @@ -99,7 +77,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) @@ -107,9 +85,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) }, } @@ -121,14 +96,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 setup hooks for (e.g., claude-code). 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() @@ -181,108 +151,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) - } - } - - // Install git hooks AFTER saving settings (InstallGitHook reads local_dev from settings) - if _, err := strategy.InstallGitHook(true); err != nil { - return fmt.Errorf("failed to install git hooks: %w", err) - } - strategy.CheckAndWarnHookManagers(w) - 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 +171,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 +178,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 @@ -374,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) } @@ -485,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()) @@ -704,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) @@ -729,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 { @@ -744,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") != "" { @@ -791,12 +636,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 2fe57bb9c..eb61e6e8a 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -343,93 +343,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 a81639451..d998cc6e0 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -15,6 +15,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" @@ -76,14 +77,15 @@ func runStatus(w io.Writer, detailed bool) error { } // Short output: just show the effective/merged state - settings, err := LoadEntireSettings() + s, err := LoadEntireSettings() if err != nil { return fmt.Errorf("failed to load settings: %w", err) } - fmt.Fprintln(w, formatSettingsStatusShort(settings)) + fmt.Fprintln(w, formatSettingsStatusShort(s)) + settings.WriteDeprecatedStrategyWarnings(w) - if settings.Enabled { + if s.Enabled { writeActiveSessions(w) } @@ -98,6 +100,7 @@ func runStatusDetailed(w io.Writer, settingsPath, localSettingsPath string, proj return fmt.Errorf("failed to load settings: %w", err) } fmt.Fprintln(w, formatSettingsStatusShort(effectiveSettings)) + settings.WriteDeprecatedStrategyWarnings(w) fmt.Fprintln(w) // blank line // Show project settings if it exists @@ -126,31 +129,21 @@ func runStatusDetailed(w io.Writer, settingsPath, localSettingsPath string, proj } // formatSettingsStatusShort formats a short settings status line. -// Output format: "Enabled (manual-commit)" or "Disabled (auto-commit)" +// Output format: "Enabled (manual-commit)" or "Disabled (manual-commit)" func formatSettingsStatusShort(settings *EntireSettings) string { - displayName := settings.Strategy - if dn, ok := strategyInternalToDisplay[settings.Strategy]; ok { - displayName = dn - } - if settings.Enabled { - return fmt.Sprintf("Enabled (%s)", displayName) + return fmt.Sprintf("Enabled (%s)", strategy.StrategyNameManualCommit) } - return fmt.Sprintf("Disabled (%s)", displayName) + return fmt.Sprintf("Disabled (%s)", strategy.StrategyNameManualCommit) } // 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 (manual-commit)" func formatSettingsStatus(prefix string, settings *EntireSettings) string { - displayName := settings.Strategy - if dn, ok := strategyInternalToDisplay[settings.Strategy]; ok { - displayName = dn - } - if settings.Enabled { - return fmt.Sprintf("%s, enabled (%s)", prefix, displayName) + return fmt.Sprintf("%s, enabled (%s)", prefix, strategy.StrategyNameManualCommit) } - return fmt.Sprintf("%s, disabled (%s)", prefix, displayName) + return fmt.Sprintf("%s, disabled (%s)", prefix, strategy.StrategyNameManualCommit) } // timeAgo formats a time as a human-readable relative duration. diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index a54dfb983..84eda2ca7 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -71,7 +71,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 { @@ -80,8 +80,8 @@ func TestRunStatus_LocalSettingsOnly(t *testing.T) { output := stdout.String() // Should show effective status first - if !strings.Contains(output, "Enabled (auto-commit)") { - t.Errorf("Expected output to show effective 'Enabled (auto-commit)', got: %s", output) + if !strings.Contains(output, "Enabled (manual-commit)") { + t.Errorf("Expected output to show effective 'Enabled (manual-commit)', got: %s", output) } // Should show per-file details if !strings.Contains(output, "Local, enabled") { @@ -95,10 +95,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 { @@ -107,25 +107,25 @@ func TestRunStatus_BothProjectAndLocal(t *testing.T) { output := stdout.String() // Should show effective status first (local overrides project) - if !strings.Contains(output, "Disabled (auto-commit)") { - t.Errorf("Expected output to show effective 'Disabled (auto-commit)', got: %s", output) + if !strings.Contains(output, "Disabled (manual-commit)") { + t.Errorf("Expected output to show effective 'Disabled (manual-commit)', got: %s", output) } // Should show both settings separately if !strings.Contains(output, "Project, enabled (manual-commit)") { t.Errorf("Expected output to show 'Project, enabled (manual-commit)', got: %s", output) } - if !strings.Contains(output, "Local, disabled (auto-commit)") { - t.Errorf("Expected output to show 'Local, disabled (auto-commit)', got: %s", output) + if !strings.Contains(output, "Local, disabled (manual-commit)") { + t.Errorf("Expected output to show 'Local, disabled (manual-commit)', got: %s", output) } } 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 { @@ -134,14 +134,14 @@ func TestRunStatus_BothProjectAndLocal_Short(t *testing.T) { output := stdout.String() // Should show merged/effective state (local overrides project) - if !strings.Contains(output, "Disabled (auto-commit)") { - t.Errorf("Expected output to show 'Disabled (auto-commit)', got: %s", output) + if !strings.Contains(output, "Disabled (manual-commit)") { + t.Errorf("Expected output to show 'Disabled (manual-commit)', got: %s", output) } } func TestRunStatus_ShowsStrategy(t *testing.T) { setupTestRepo(t) - writeSettings(t, `{"strategy": "auto-commit", "enabled": true}`) + writeSettings(t, `{"enabled": true}`) var stdout bytes.Buffer if err := runStatus(&stdout, false); err != nil { @@ -149,14 +149,14 @@ func TestRunStatus_ShowsStrategy(t *testing.T) { } output := stdout.String() - if !strings.Contains(output, "(auto-commit)") { - t.Errorf("Expected output to show strategy '(auto-commit)', got: %s", output) + if !strings.Contains(output, "(manual-commit)") { + t.Errorf("Expected output to show strategy '(manual-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 { @@ -174,6 +174,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 diff --git a/cmd/entire/cli/strategy/auto_commit.go b/cmd/entire/cli/strategy/auto_commit.go deleted file mode 100644 index 5ee5ced3e..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); 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 70a4497d9..e66656cd7 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 @@ -722,73 +747,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 { @@ -1329,7 +1290,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/manual_commit.go b/cmd/entire/cli/strategy/manual_commit.go index 499732c0b..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); 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 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 4ce450093..9b2940971 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 d35fe62ff..c4eb033b5 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 |