From 48fe63559934c2d29b0a04e5a3aef8b03ffe7c65 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Wed, 18 Feb 2026 16:26:25 +1100 Subject: [PATCH 1/4] delete auto-commit strat Entire-Checkpoint: 9257c9f8b2a3 --- .claude/skills/test-repo/SKILL.md | 34 +- .claude/skills/test-repo/test-harness.sh | 2 +- .github/ISSUE_TEMPLATE/bug_report.yml | 2 - CLAUDE.md | 23 +- GEMINI.md | 18 +- README.md | 8 +- cmd/entire/cli/checkpoint/checkpoint.go | 2 +- cmd/entire/cli/clean.go | 4 +- cmd/entire/cli/config.go | 22 +- cmd/entire/cli/config_test.go | 87 +- cmd/entire/cli/debug.go | 363 +----- .../e2e_test/scenario_agent_commit_test.go | 4 +- .../e2e_test/scenario_basic_workflow_test.go | 4 +- .../cli/e2e_test/scenario_checkpoint_test.go | 56 +- .../scenario_checkpoint_workflows_test.go | 36 +- .../cli/e2e_test/scenario_rewind_test.go | 6 +- .../cli/e2e_test/scenario_subagent_test.go | 4 +- cmd/entire/cli/e2e_test/testenv.go | 7 +- cmd/entire/cli/explain_test.go | 16 +- .../auto_commit_checkpoint_fix_test.go | 318 ----- cmd/entire/cli/integration_test/hooks_test.go | 45 +- .../cli/integration_test/resume_test.go | 37 +- .../cli/integration_test/rewind_test.go | 46 - .../subagent_checkpoints_test.go | 14 +- cmd/entire/cli/integration_test/testenv.go | 3 +- .../cli/integration_test/testenv_test.go | 11 +- .../cli/integration_test/worktree_test.go | 4 +- cmd/entire/cli/lifecycle.go | 43 +- cmd/entire/cli/paths/paths.go | 2 +- cmd/entire/cli/reset.go | 2 - cmd/entire/cli/reset_test.go | 24 - cmd/entire/cli/resume_test.go | 71 +- cmd/entire/cli/root.go | 3 +- cmd/entire/cli/settings/settings.go | 29 +- cmd/entire/cli/settings/settings_test.go | 8 +- cmd/entire/cli/setup.go | 193 +-- cmd/entire/cli/setup_test.go | 87 -- cmd/entire/cli/status.go | 23 +- cmd/entire/cli/status_test.go | 38 +- cmd/entire/cli/strategy/auto_commit.go | 1105 ----------------- cmd/entire/cli/strategy/auto_commit_test.go | 1037 ---------------- cmd/entire/cli/strategy/clean_test.go | 2 +- cmd/entire/cli/strategy/common.go | 99 +- cmd/entire/cli/strategy/manual_commit.go | 24 - cmd/entire/cli/strategy/push_common.go | 1 - cmd/entire/cli/strategy/registry.go | 24 - cmd/entire/cli/strategy/rewind_test.go | 33 - cmd/entire/cli/strategy/session.go | 1 - cmd/entire/cli/strategy/strategy.go | 11 +- docs/architecture/claude-hooks-integration.md | 3 +- docs/architecture/logging.md | 1 - docs/architecture/sessions-and-checkpoints.md | 7 - 52 files changed, 205 insertions(+), 3842 deletions(-) delete mode 100644 cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go delete mode 100644 cmd/entire/cli/strategy/auto_commit.go delete mode 100644 cmd/entire/cli/strategy/auto_commit_test.go diff --git a/.claude/skills/test-repo/SKILL.md b/.claude/skills/test-repo/SKILL.md index ca5ad29e7..a9291b6d6 100644 --- a/.claude/skills/test-repo/SKILL.md +++ b/.claude/skills/test-repo/SKILL.md @@ -10,7 +10,7 @@ This skill validates the CLI's session management and rewind functionality by ru ## When to Use - User asks to "test against a test repo" -- User wants to validate strategy changes (manual-commit, auto-commit, shadow, dual) +- User wants to validate strategy changes (manual-commit) - User asks to verify session hooks, commits, or rewind functionality - After making changes to strategy code @@ -54,7 +54,7 @@ Add this pattern to your Claude Code approved commands, or approve it once when **Optional: Set strategy** (defaults to `manual-commit`): ```bash -export STRATEGY=manual-commit # or auto-commit, shadow, dual +export STRATEGY=manual-commit ``` ### Test Steps @@ -87,15 +87,15 @@ Execute these steps in order: .claude/skills/test-repo/test-harness.sh list-rewind-points ``` -Expected results by strategy: +Expected results: -| Check | manual-commit/shadow | auto-commit/dual | -|-------|---------------------|------------------| -| Active branch | No Entire-* trailers | Entire-Checkpoint: trailer only | -| Session state | ✓ Exists | ✗ Not used | -| Shadow branch | ✓ entire/{hash} | ✗ None | -| Metadata branch | ✓ entire/checkpoints/v1 | ✓ entire/checkpoints/v1 | -| Rewind points | ✓ At least 1 | ✓ At least 1 | +| Check | Result | +|-------|--------| +| Active branch | Optional Entire-Checkpoint: trailer | +| Session state | ✓ Exists | +| Shadow branch | ✓ entire/{hash} | +| Metadata branch | ✓ entire/checkpoints/v1 | +| Rewind points | ✓ At least 1 | #### 4. Test Rewind @@ -107,8 +107,7 @@ Expected results by strategy: ``` **Expected Behavior:** -- **Manual-commit/shadow**: Shows warning listing untracked files that will be deleted (files created after the checkpoint that weren't present at session start) -- **Auto-commit/dual**: No warning (git reset doesn't delete untracked files) +- Shows warning listing untracked files that will be deleted (files created after the checkpoint that weren't present at session start) Example warning output (manual-commit): ``` @@ -144,7 +143,7 @@ go build -o /tmp/entire-bin ./cmd/entire && \ ## Expected Results by Strategy -### Manual-Commit Strategy (default, alias: shadow) +### Manual-Commit Strategy (default) - Active branch commits: **NO modifications** (no commits created by Entire) - Shadow branches: `entire/` created for checkpoints - Metadata: stored on both shadow branches and `entire/checkpoints/v1` branch (condensed on user commits) @@ -153,15 +152,6 @@ go build -o /tmp/entire-bin ./cmd/entire && \ - Preserves untracked files that existed at session start - AllowsMainBranch: **true** (safe on main/master) -### Auto-Commit Strategy (alias: dual) -- Active branch commits: **clean commits** with only `Entire-Checkpoint: <12-hex-char>` trailer -- Shadow branches: none -- Metadata: stored on orphan `entire/checkpoints/v1` branch at sharded paths -- Rewind: full reset allowed if commit is only on current branch - - Uses `git reset --hard` which doesn't delete untracked files - - **No preview warnings** (untracked files are safe) -- AllowsMainBranch: **false** (creates commits on active branch) - ## Additional Testing (Optional) ### Test Subagent Checkpoints diff --git a/.claude/skills/test-repo/test-harness.sh b/.claude/skills/test-repo/test-harness.sh index 3e257fac1..cbd5e4686 100755 --- a/.claude/skills/test-repo/test-harness.sh +++ b/.claude/skills/test-repo/test-harness.sh @@ -130,7 +130,7 @@ verify-shadow-branch) if git branch -a | grep -E "entire/[0-9a-f]"; then echo "✓ Shadow branch exists" else - echo "Note: No shadow branch (expected for auto-commit strategy)" + echo "Note: No shadow branch" fi ;; diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5a3e21afd..eba2bf4a8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -67,8 +67,6 @@ body: description: "Which strategy is configured? (check `.entire/settings.json` or `entire status`)" options: - manual-commit (default) - - auto-commit - - Not sure validations: required: true diff --git a/CLAUDE.md b/CLAUDE.md index 481281087..5dbbe4b7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,8 +288,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 @@ -307,16 +306,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.) @@ -334,7 +323,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/`) @@ -432,10 +420,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: @@ -500,12 +487,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 0b2274521..c1493be0e 100644 --- a/README.md +++ b/README.md @@ -185,15 +185,11 @@ Multiple AI sessions can run on the same commit. If you start a second session w | `--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 @@ -234,7 +230,7 @@ Personal overrides, gitignored by default: |--------------------------------------|----------------------------------|------------------------------------------------------| | `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 | @@ -291,7 +287,7 @@ Entire automatically redacts detected secrets (API keys, tokens, credentials) wh |--------------------------|-------------------------------------------------------------------------------------------| | "Not a git repository" | Navigate to a Git repository first | | "Entire is disabled" | Run `entire enable` | -| "No rewind points found" | Work with Claude Code and commit (manual-commit) or wait for agent response (auto-commit) | +| "No rewind points found" | Work with Claude Code 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 79d8d9a05..bc826f591 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..7debf43bc 100644 --- a/cmd/entire/cli/config_test.go +++ b/cmd/entire/cli/config_test.go @@ -6,13 +6,11 @@ import ( "strings" "testing" - "github.com/entireio/cli/cmd/entire/cli/strategy" ) const ( - testSettingsStrategy = `{"strategy": "manual-commit"}` - testSettingsEnabled = `{"strategy": "manual-commit", "enabled": true}` - testSettingsDisabled = `{"strategy": "manual-commit", "enabled": false}` + testSettingsEnabled = `{"enabled": true}` + testSettingsDisabled = `{"enabled": false}` ) func TestLoadEntireSettings_EnabledDefaultsToTrue(t *testing.T) { @@ -34,7 +32,7 @@ func TestLoadEntireSettings_EnabledDefaultsToTrue(t *testing.T) { if err := os.MkdirAll(settingsDir, 0o755); err != nil { t.Fatalf("Failed to create settings dir: %v", err) } - settingsContent := testSettingsStrategy + settingsContent := `{}` if err := os.WriteFile(EntireSettingsFile, []byte(settingsContent), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -82,8 +80,7 @@ func TestSaveEntireSettings_PreservesEnabled(t *testing.T) { // Save settings with Enabled = false settings := &EntireSettings{ - Strategy: "manual-commit", - Enabled: false, + Enabled: false, } if err := SaveEntireSettings(settings); err != nil { t.Fatalf("SaveEntireSettings() error = %v", err) @@ -165,7 +162,7 @@ func TestLoadEntireSettings_LocalOverridesStrategy(t *testing.T) { t.Fatalf("Failed to write settings file: %v", err) } - localSettings := `{"strategy": "` + strategy.StrategyNameAutoCommit + `"}` + localSettings := `{"enabled": true}` if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -174,9 +171,6 @@ func TestLoadEntireSettings_LocalOverridesStrategy(t *testing.T) { if err != nil { t.Fatalf("LoadEntireSettings() error = %v", err) } - if settings.Strategy != strategy.StrategyNameAutoCommit { - t.Errorf("Strategy should be 'auto-commit' from local override, got %q", settings.Strategy) - } if !settings.Enabled { t.Error("Enabled should remain true from base settings") } @@ -202,15 +196,12 @@ func TestLoadEntireSettings_LocalOverridesEnabled(t *testing.T) { if settings.Enabled { t.Error("Enabled should be false from local override") } - if settings.Strategy != strategy.StrategyNameManualCommit { - t.Errorf("Strategy should remain 'manual-commit' from base settings, got %q", settings.Strategy) - } } func TestLoadEntireSettings_LocalOverridesLocalDev(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := testSettingsStrategy + baseSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -232,7 +223,7 @@ func TestLoadEntireSettings_LocalOverridesLocalDev(t *testing.T) { func TestLoadEntireSettings_LocalMergesStrategyOptions(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := `{"strategy": "manual-commit", "strategy_options": {"key1": "value1", "key2": "value2"}}` + baseSettings := `{"enabled": true, "strategy_options": {"key1": "value1", "key2": "value2"}}` if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -262,7 +253,7 @@ func TestLoadEntireSettings_OnlyLocalFileExists(t *testing.T) { setupLocalOverrideTestDir(t) // No base settings file - localSettings := `{"strategy": "auto-commit"}` + localSettings := `{"enabled": true}` if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -271,64 +262,6 @@ func TestLoadEntireSettings_OnlyLocalFileExists(t *testing.T) { if err != nil { t.Fatalf("LoadEntireSettings() error = %v", err) } - if settings.Strategy != strategyDisplayAutoCommit { - t.Errorf("Strategy should be 'auto-commit' from local file, got %q", settings.Strategy) - } - if !settings.Enabled { - t.Error("Enabled should default to true") - } -} - -func TestLoadEntireSettings_NoLocalFileUsesBase(t *testing.T) { - setupLocalOverrideTestDir(t) - - baseSettings := `{"strategy": "manual-commit", "enabled": true}` - if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { - t.Fatalf("Failed to write settings file: %v", err) - } - - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - if settings.Strategy != "manual-commit" { - t.Errorf("Strategy should be 'shadow' from base settings, got %q", settings.Strategy) - } -} - -func TestLoadEntireSettings_EmptyStrategyInLocalDoesNotOverride(t *testing.T) { - setupLocalOverrideTestDir(t) - - baseSettings := testSettingsStrategy - if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { - t.Fatalf("Failed to write settings file: %v", err) - } - - localSettings := `{"strategy": ""}` - if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { - t.Fatalf("Failed to write local settings file: %v", err) - } - - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - if settings.Strategy != "manual-commit" { - t.Errorf("Strategy should remain 'shadow', got %q", settings.Strategy) - } -} - -func TestLoadEntireSettings_NeitherFileExistsReturnsDefaults(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - settings, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - if settings.Strategy != strategy.DefaultStrategyName { - t.Errorf("Strategy should be default %q, got %q", strategy.DefaultStrategyName, settings.Strategy) - } if !settings.Enabled { t.Error("Enabled should default to true") } @@ -337,7 +270,7 @@ func TestLoadEntireSettings_NeitherFileExistsReturnsDefaults(t *testing.T) { func TestLoadEntireSettings_RejectsUnknownKeysInBase(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := `{"strategy": "manual-commit", "bogus_key": true}` + baseSettings := `{"bogus_key": true}` if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -354,7 +287,7 @@ func TestLoadEntireSettings_RejectsUnknownKeysInBase(t *testing.T) { func TestLoadEntireSettings_RejectsUnknownKeysInLocal(t *testing.T) { setupLocalOverrideTestDir(t) - baseSettings := `{"strategy": "manual-commit"}` + baseSettings := `{}` if err := os.WriteFile(EntireSettingsFile, []byte(baseSettings), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } diff --git a/cmd/entire/cli/debug.go b/cmd/entire/cli/debug.go 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/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 a7eeede33..814883154 100644 --- a/cmd/entire/cli/explain_test.go +++ b/cmd/entire/cli/explain_test.go @@ -453,7 +453,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{ { @@ -508,7 +508,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 } @@ -594,7 +594,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{}, } @@ -625,7 +625,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{}, } @@ -652,7 +652,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{}, } @@ -690,7 +690,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{}, } @@ -729,7 +729,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{}, } @@ -3525,7 +3525,7 @@ func TestGetBranchCheckpoints_DefaultBranchFindsMergedCheckpoints(t *testing.T) if err := store.WriteCommitted(context.Background(), checkpoint.WriteCommittedOptions{ CheckpointID: cpID, SessionID: "test-session", - Strategy: "auto-commit", + Strategy: "manual-commit", FilesTouched: []string{"test.txt"}, Prompts: []string{"add feature"}, }); err != nil { diff --git a/cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go b/cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go deleted file mode 100644 index b314580a2..000000000 --- a/cmd/entire/cli/integration_test/auto_commit_checkpoint_fix_test.go +++ /dev/null @@ -1,318 +0,0 @@ -//go:build integration - -package integration - -import ( - "strings" - "testing" - - "github.com/entireio/cli/cmd/entire/cli/paths" - "github.com/entireio/cli/cmd/entire/cli/strategy" -) - -// TestDualStrategy_NoCheckpointForNoChanges verifies that the auto-commit strategy -// does NOT create a checkpoint when a prompt results in no file changes, -// even after a previous prompt that DID create file changes. -// -// This is the fix for ENT-70: auto-commit strategy was incorrectly triggering checkpoints -// because it parsed the entire transcript including file changes from previous prompts. -func TestDualStrategy_NoCheckpointForNoChanges(t *testing.T) { - t.Parallel() - - // Only run for auto-commit strategy - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) - - // Create a session - session := env.NewSession() - - // === FIRST PROMPT: Creates a file === - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) - } - - // Create a file (as if Claude Code wrote it) - env.WriteFile("feature.go", "package feature\n\nfunc Hello() {}\n") - - // Create transcript for first prompt - session.TranscriptBuilder.AddUserMessage("Create a hello function") - session.TranscriptBuilder.AddAssistantMessage("I'll create that for you.") - toolID := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "feature.go", "package feature\n\nfunc Hello() {}\n") - session.TranscriptBuilder.AddToolResult(toolID) - session.TranscriptBuilder.AddAssistantMessage("Done!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Get head hash before first stop - hashBeforeFirstStop := env.GetHeadHash() - - // Simulate stop for first prompt - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("First SimulateStop failed: %v", err) - } - - // Verify a commit was created (auto-commit creates commits on active branch) - hashAfterFirstStop := env.GetHeadHash() - if hashAfterFirstStop == hashBeforeFirstStop { - t.Error("Expected commit to be created after first prompt with file changes") - } - - // === SECOND PROMPT: No file changes === - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) - } - - // Add second prompt to transcript (no file changes this time) - session.TranscriptBuilder.AddUserMessage("What does the Hello function do?") - session.TranscriptBuilder.AddAssistantMessage("The Hello function is currently empty. It doesn't do anything yet.") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Simulate stop for second prompt - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Second SimulateStop failed: %v", err) - } - - // Verify NO new commit was created (this is the bug fix!) - hashAfterSecondStop := env.GetHeadHash() - if hashAfterSecondStop != hashAfterFirstStop { - t.Errorf("No commit should be created for prompt without file changes.\nHash after first stop: %s\nHash after second stop: %s", - hashAfterFirstStop, hashAfterSecondStop) - } - - // === THIRD PROMPT: Has file changes again === - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Third SimulateUserPromptSubmit failed: %v", err) - } - - // Create another file - env.WriteFile("feature2.go", "package feature\n\nfunc Goodbye() {}\n") - - // Add third prompt to transcript with file changes - session.TranscriptBuilder.AddUserMessage("Add a Goodbye function") - session.TranscriptBuilder.AddAssistantMessage("I'll add that.") - toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "feature2.go", "package feature\n\nfunc Goodbye() {}\n") - session.TranscriptBuilder.AddToolResult(toolID2) - session.TranscriptBuilder.AddAssistantMessage("Done!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Simulate stop for third prompt - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Third SimulateStop failed: %v", err) - } - - // Verify a commit WAS created for the third prompt - hashAfterThirdStop := env.GetHeadHash() - if hashAfterThirdStop == hashAfterSecondStop { - t.Error("Expected commit to be created after third prompt with file changes") - } -} - -// TestDualStrategy_IncrementalPromptContent verifies that each checkpoint only -// includes prompts since the last checkpoint, not the entire session history. -// -// This is the auto-commit equivalent of the manual-commit incremental condensation test. -// For auto-commit strategy, each checkpoint creates a commit, so the prompt.txt should only -// contain prompts from that specific checkpoint, not previous ones. -func TestDualStrategy_IncrementalPromptContent(t *testing.T) { - t.Parallel() - - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) - session := env.NewSession() - - // === FIRST PROMPT: Creates file A === - t.Log("Phase 1: First prompt creates file A") - - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("First SimulateUserPromptSubmit failed: %v", err) - } - - fileAContent := "package main\n\nfunc FunctionA() {}\n" - env.WriteFile("a.go", fileAContent) - - session.TranscriptBuilder.AddUserMessage("Create function A for the first feature") - session.TranscriptBuilder.AddAssistantMessage("I'll create function A for you.") - toolID1 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "a.go", fileAContent) - session.TranscriptBuilder.AddToolResult(toolID1) - session.TranscriptBuilder.AddAssistantMessage("Done creating function A!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("First SimulateStop failed: %v", err) - } - - // Get checkpoint ID from first commit - commit1Hash := env.GetHeadHash() - checkpoint1ID := env.GetCheckpointIDFromCommitMessage(commit1Hash) - t.Logf("First checkpoint: %s (commit %s)", checkpoint1ID, commit1Hash[:7]) - - // Verify first checkpoint has prompt A (session files in numbered subdirectory) - prompt1Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint1ID, "prompt.txt")) - if !found { - t.Fatal("First checkpoint should have prompt.txt on entire/checkpoints/v1 branch") - } - t.Logf("First checkpoint prompt.txt:\n%s", prompt1Content) - - if !strings.Contains(prompt1Content, "function A") { - t.Error("First checkpoint prompt.txt should contain 'function A'") - } - - // === SECOND PROMPT: Creates file B === - t.Log("Phase 2: Second prompt creates file B") - - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) - } - - fileBContent := "package main\n\nfunc FunctionB() {}\n" - env.WriteFile("b.go", fileBContent) - - session.TranscriptBuilder.AddUserMessage("Create function B for the second feature") - session.TranscriptBuilder.AddAssistantMessage("I'll create function B for you.") - toolID2 := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "b.go", fileBContent) - session.TranscriptBuilder.AddToolResult(toolID2) - session.TranscriptBuilder.AddAssistantMessage("Done creating function B!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Second SimulateStop failed: %v", err) - } - - // Get checkpoint ID from second commit - commit2Hash := env.GetHeadHash() - checkpoint2ID := env.GetCheckpointIDFromCommitMessage(commit2Hash) - t.Logf("Second checkpoint: %s (commit %s)", checkpoint2ID, commit2Hash[:7]) - - if checkpoint1ID == checkpoint2ID { - t.Error("Checkpoints should have different IDs") - } - - // === VERIFY INCREMENTAL CONTENT === - t.Log("Phase 3: Verify second checkpoint only has prompt B (incremental)") - - // Session files are now in numbered subdirectory (e.g., 0/prompt.txt) - prompt2Content, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpoint2ID, "prompt.txt")) - if !found { - t.Fatal("Second checkpoint should have prompt.txt on entire/checkpoints/v1 branch") - } - t.Logf("Second checkpoint prompt.txt:\n%s", prompt2Content) - - // Should contain prompt B - if !strings.Contains(prompt2Content, "function B") { - t.Error("Second checkpoint prompt.txt should contain 'function B'") - } - - // Should NOT contain prompt A (already in first checkpoint) - if strings.Contains(prompt2Content, "function A") { - t.Error("Second checkpoint prompt.txt should NOT contain 'function A' (already in first checkpoint)") - } - - t.Log("Incremental prompt content test completed successfully!") -} - -// TestDualStrategy_SessionStateTracksTranscriptOffset verifies that session state -// correctly tracks the transcript offset (CheckpointTranscriptStart) across prompts. -// Note: cannot use t.Parallel() because we need t.Chdir to load session state. -func TestDualStrategy_SessionStateTracksTranscriptOffset(t *testing.T) { - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) - session := env.NewSession() - - // First prompt - err := env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - // Session state is created by InitializeSession during UserPromptSubmit - // We need to change to the repo directory to load session state (it uses GetGitCommonDir) - t.Chdir(env.RepoDir) - state, err := strategy.LoadSessionState(session.ID) - if err != nil { - t.Fatalf("LoadSessionState failed: %v", err) - } - if state == nil { - t.Fatal("Session state should have been created by InitializeSession") - } - if state.CheckpointTranscriptStart != 0 { - t.Errorf("Initial CheckpointTranscriptStart should be 0, got %d", state.CheckpointTranscriptStart) - } - if state.StepCount != 0 { - t.Errorf("Initial StepCount should be 0, got %d", state.StepCount) - } - - // Create file and transcript - env.WriteFile("test.go", "package test") - session.CreateTranscript("Create test file", []FileChange{ - {Path: "test.go", Content: "package test"}, - }) - - // Simulate stop - this should update CheckpointTranscriptStart - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - // Verify session state was updated with transcript position - state, err = strategy.LoadSessionState(session.ID) - if err != nil { - t.Fatalf("LoadSessionState after stop failed: %v", err) - } - if state.CheckpointTranscriptStart == 0 { - t.Error("CheckpointTranscriptStart should have been updated after checkpoint") - } - if state.StepCount != 1 { - t.Errorf("StepCount should be 1, got %d", state.StepCount) - } - - // Second prompt - add more to transcript - err = env.SimulateUserPromptSubmit(session.ID) - if err != nil { - t.Fatalf("Second SimulateUserPromptSubmit failed: %v", err) - } - - // Modify a file - env.WriteFile("test.go", "package test\n\nfunc Test() {}\n") - session.TranscriptBuilder.AddUserMessage("Add a test function") - session.TranscriptBuilder.AddAssistantMessage("Adding test function.") - toolID := session.TranscriptBuilder.AddToolUse("mcp__acp__Write", "test.go", "package test\n\nfunc Test() {}\n") - session.TranscriptBuilder.AddToolResult(toolID) - session.TranscriptBuilder.AddAssistantMessage("Done!") - if err := session.TranscriptBuilder.WriteToFile(session.TranscriptPath); err != nil { - t.Fatalf("Failed to write transcript: %v", err) - } - - // Simulate second stop - err = env.SimulateStop(session.ID, session.TranscriptPath) - if err != nil { - t.Fatalf("Second SimulateStop failed: %v", err) - } - - // Verify session state was updated again - state, err = strategy.LoadSessionState(session.ID) - if err != nil { - t.Fatalf("LoadSessionState after second stop failed: %v", err) - } - if state.StepCount != 2 { - t.Errorf("StepCount should be 2, got %d", state.StepCount) - } - // CheckpointTranscriptStart should be higher than after first stop - t.Logf("Final CheckpointTranscriptStart: %d, StepCount: %d", - state.CheckpointTranscriptStart, state.StepCount) -} diff --git a/cmd/entire/cli/integration_test/hooks_test.go b/cmd/entire/cli/integration_test/hooks_test.go index 6e3a568bc..9726a9fa4 100644 --- a/cmd/entire/cli/integration_test/hooks_test.go +++ b/cmd/entire/cli/integration_test/hooks_test.go @@ -142,9 +142,6 @@ func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { t.Fatalf("SimulateUserPromptSubmit failed: %v", err) } - // Record initial state for comparison - commitsBefore := env.GetGitLog() - // Create a file on disk (simulating what a subagent would write) env.WriteFile("subagent_output.go", "package main\n\nfunc SubagentWork() {}\n") @@ -193,34 +190,22 @@ func TestHookRunner_SimulateStop_SubagentOnlyChanges(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } - // Verify checkpoint was created based on strategy type - switch strategyName { - case strategy.StrategyNameAutoCommit: - // Auto-commit creates a new commit on the active branch - commitsAfter := env.GetGitLog() - if len(commitsAfter) <= len(commitsBefore) { - t.Errorf("auto-commit: expected new commit to be created; commits before=%d, after=%d", - len(commitsBefore), len(commitsAfter)) - } - - case strategy.StrategyNameManualCommit: - // Manual-commit stores checkpoint data on the shadow branch - shadowBranch := env.GetShadowBranchName() - if !env.BranchExists(shadowBranch) { - t.Errorf("manual-commit: shadow branch %s should exist after checkpoint", shadowBranch) - } + // Verify checkpoint was created (manual-commit stores checkpoint data on the shadow branch) + shadowBranch := env.GetShadowBranchName() + if !env.BranchExists(shadowBranch) { + t.Errorf("shadow branch %s should exist after checkpoint", shadowBranch) + } - // Verify session state was updated with checkpoint count - state, stateErr := env.GetSessionState(session.ID) - if stateErr != nil { - t.Fatalf("failed to get session state: %v", stateErr) - } - if state == nil { - t.Fatal("manual-commit: session state should exist after checkpoint") - } - if state.StepCount == 0 { - t.Error("manual-commit: session state should have non-zero step count") - } + // Verify session state was updated with checkpoint count + state, stateErr := env.GetSessionState(session.ID) + if stateErr != nil { + t.Fatalf("failed to get session state: %v", stateErr) + } + if state == nil { + t.Fatal("session state should exist after checkpoint") + } + if state.StepCount == 0 { + t.Error("session state should have non-zero step count") } }) } diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index 785e0807b..b7b30a950 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -20,18 +20,11 @@ import ( const masterBranch = "master" -// Note: Resume tests only run with auto-commit strategy because: -// - Auto-commit strategy creates commits with Entire-Checkpoint trailers and metadata on entire/checkpoints/v1 -// immediately during SimulateStop -// - Manual-commit strategy only creates this structure after user commits (via prepare-commit-msg -// and post-commit hooks), which requires the full workflow tested in manual_commit_workflow_test.go -// Both strategies share the same resume code path once the structure exists. - // TestResume_SwitchBranchWithSession tests the resume command when switching to a branch // that has a commit with an Entire-Checkpoint trailer. func TestResume_SwitchBranchWithSession(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session on the feature branch session := env.NewSession() @@ -93,7 +86,7 @@ func TestResume_SwitchBranchWithSession(t *testing.T) { // TestResume_AlreadyOnBranch tests that resume works when already on the target branch. func TestResume_AlreadyOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session on the feature branch session := env.NewSession() @@ -130,7 +123,7 @@ func TestResume_AlreadyOnBranch(t *testing.T) { // any Entire-Checkpoint trailer in their history gracefully. func TestResume_NoCheckpointOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a branch directly from master (which has no checkpoints) // Switch to master first @@ -169,7 +162,7 @@ func TestResume_NoCheckpointOnBranch(t *testing.T) { // TestResume_BranchDoesNotExist tests that resume returns an error for non-existent branches. func TestResume_BranchDoesNotExist(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Try to resume a non-existent branch output, err := env.RunResume("nonexistent-branch") @@ -188,7 +181,7 @@ func TestResume_BranchDoesNotExist(t *testing.T) { // TestResume_UncommittedChanges tests that resume fails when there are uncommitted changes. func TestResume_UncommittedChanges(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create another branch env.GitCheckoutNewBranch("feature/target") @@ -220,7 +213,7 @@ func TestResume_UncommittedChanges(t *testing.T) { // with the checkpoint's version. This ensures consistency when resuming from a different device. func TestResume_SessionLogAlreadyExists(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session session := env.NewSession() @@ -284,7 +277,7 @@ func TestResume_SessionLogAlreadyExists(t *testing.T) { // ensuring it uses the session from the last commit. func TestResume_MultipleSessionsOnBranch(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create first session session1 := env.NewSession() @@ -348,7 +341,7 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { // This can happen if the metadata branch was corrupted or reset. func TestResume_CheckpointWithoutMetadata(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // First create a real session so the entire/checkpoints/v1 branch exists session := env.NewSession() @@ -404,7 +397,7 @@ func TestResume_CheckpointWithoutMetadata(t *testing.T) { // Since the only "newer" commits are merge commits, no confirmation should be required. func TestResume_AfterMergingMain(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session on the feature branch session := env.NewSession() @@ -580,7 +573,7 @@ func (env *TestEnv) GitCheckoutBranch(branchName string) { // and does NOT overwrite the local log. This ensures safe behavior in CI environments. func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -638,7 +631,7 @@ func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { // and overwrites the local log. func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -697,7 +690,7 @@ func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { // confirms the overwrite prompt interactively, the local log is overwritten. func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -761,7 +754,7 @@ func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { // declines the overwrite prompt interactively, the local log is preserved. func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session with a specific timestamp session := env.NewSession() @@ -826,7 +819,7 @@ func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { // than local log, resume proceeds without requiring --force. func TestResume_CheckpointNewerTimestamp(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session session := env.NewSession() @@ -992,7 +985,7 @@ func TestResume_MultiSessionMixedTimestamps(t *testing.T) { // resume proceeds without requiring --force (treated as new). func TestResume_LocalLogNoTimestamp(t *testing.T) { t.Parallel() - env := NewFeatureBranchEnv(t, strategy.StrategyNameAutoCommit) + env := NewFeatureBranchEnv(t, strategy.StrategyNameManualCommit) // Create a session session := env.NewSession() diff --git a/cmd/entire/cli/integration_test/rewind_test.go b/cmd/entire/cli/integration_test/rewind_test.go index d93f1588d..e6f9034ba 100644 --- a/cmd/entire/cli/integration_test/rewind_test.go +++ b/cmd/entire/cli/integration_test/rewind_test.go @@ -437,49 +437,3 @@ func TestRewind_MultipleConsecutive(t *testing.T) { }) } - -// TestRewind_DifferentSessions tests that commit and auto-commit strategies support -// multiple different sessions without committing, while manual-commit strategy requires -// the same session (or a commit between sessions). -func TestRewind_DifferentSessions(t *testing.T) { - t.Parallel() - - t.Run("auto_commit_supports_different_sessions", func(t *testing.T) { - t.Parallel() - for _, strategyName := range []string{"auto-commit"} { - strategyName := strategyName // capture for parallel - t.Run(strategyName, func(t *testing.T) { - t.Parallel() - env := NewFeatureBranchEnv(t, strategyName) - - // Session 1 - session1 := env.NewSession() - if err := env.SimulateUserPromptSubmit(session1.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit session1 failed: %v", err) - } - env.WriteFile("file.txt", "version 1") - session1.CreateTranscript("Create file", []FileChange{{Path: "file.txt", Content: "version 1"}}) - if err := env.SimulateStop(session1.ID, session1.TranscriptPath); err != nil { - t.Fatalf("SimulateStop session1 failed: %v", err) - } - - // Session 2 (different session ID, no commit between) - session2 := env.NewSession() - if err := env.SimulateUserPromptSubmit(session2.ID); err != nil { - t.Fatalf("SimulateUserPromptSubmit session2 failed: %v", err) - } - env.WriteFile("file.txt", "version 2") - session2.CreateTranscript("Update file", []FileChange{{Path: "file.txt", Content: "version 2"}}) - if err := env.SimulateStop(session2.ID, session2.TranscriptPath); err != nil { - t.Fatalf("SimulateStop session2 failed: %v", err) - } - - // Both sessions should create rewind points - points := env.GetRewindPoints() - if len(points) != 2 { - t.Errorf("expected 2 rewind points, got %d", len(points)) - } - }) - } - }) -} diff --git a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go index a3d614c91..da96068b2 100644 --- a/cmd/entire/cli/integration_test/subagent_checkpoints_test.go +++ b/cmd/entire/cli/integration_test/subagent_checkpoints_test.go @@ -290,17 +290,9 @@ func TestSubagentCheckpoints_NoPreTaskFile(t *testing.T) { func verifyCheckpointStorage(t *testing.T, env *TestEnv, strategyName, sessionID, taskToolUseID string) { t.Helper() - switch strategyName { - case strategy.StrategyNameManualCommit: - // Shadow strategy stores checkpoints in git tree on shadow branch (entire/) - // We need to verify that checkpoint data exists in the shadow branch tree - verifyShadowCheckpointStorage(t, env, sessionID, taskToolUseID) - - case strategy.StrategyNameAutoCommit: - // Dual strategy stores metadata on orphan entire/checkpoints/v1 branch - // Verify that commits were created (incremental + final) - t.Logf("Note: auto-commit strategy stores checkpoints in entire/checkpoints/v1 branch") - } + // Manual-commit stores checkpoints in git tree on shadow branch (entire/) + // We need to verify that checkpoint data exists in the shadow branch tree + verifyShadowCheckpointStorage(t, env, sessionID, taskToolUseID) } // verifyShadowCheckpointStorage verifies that checkpoints are stored in the shadow branch git tree. diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index e51b9b297..55e01741e 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -189,7 +189,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, } } @@ -469,7 +468,7 @@ func (env *TestEnv) GitCommitWithMetadata(message, metadataDir string) { } // GitCommitWithCheckpointID creates a commit with Entire-Checkpoint trailer. -// This simulates commits created by the auto-commit strategy. +// This simulates commits. func (env *TestEnv) GitCommitWithCheckpointID(message, checkpointID string) { env.T.Helper() diff --git a/cmd/entire/cli/integration_test/testenv_test.go b/cmd/entire/cli/integration_test/testenv_test.go index ff328b554..0d47c02db 100644 --- a/cmd/entire/cli/integration_test/testenv_test.go +++ b/cmd/entire/cli/integration_test/testenv_test.go @@ -56,7 +56,7 @@ func TestTestEnv_InitEntire(t *testing.T) { t.Error(".entire directory should exist") } - // Verify settings file exists and contains strategy + // Verify settings file exists and contains enabled settingsPath := filepath.Join(entireDir, paths.SettingsFileName) data, err := os.ReadFile(settingsPath) if err != nil { @@ -64,9 +64,8 @@ func TestTestEnv_InitEntire(t *testing.T) { } settingsContent := string(data) - expectedStrategy := `"strategy": "` + strategyName + `"` - if !strings.Contains(settingsContent, expectedStrategy) { - t.Errorf("settings.json should contain %s, got: %s", expectedStrategy, settingsContent) + if !strings.Contains(settingsContent, `"enabled"`) { + t.Errorf("settings.json should contain enabled field, got: %s", settingsContent) } // Verify tmp directory exists @@ -211,12 +210,12 @@ func TestNewFeatureBranchEnv(t *testing.T) { func TestAllStrategies(t *testing.T) { t.Parallel() strategies := AllStrategies() - if len(strategies) != 2 { + if len(strategies) != 1 { t.Errorf("AllStrategies() returned %d strategies, want 2", len(strategies)) } // Verify expected strategies are present - expected := []string{"auto-commit", "manual-commit"} + expected := []string{"manual-commit"} for _, exp := range expected { found := false for _, s := range strategies { diff --git a/cmd/entire/cli/integration_test/worktree_test.go b/cmd/entire/cli/integration_test/worktree_test.go index 4cda6f275..2a3f62145 100644 --- a/cmd/entire/cli/integration_test/worktree_test.go +++ b/cmd/entire/cli/integration_test/worktree_test.go @@ -22,9 +22,9 @@ import ( // // NOTE: This test uses os.Chdir() so it cannot use t.Parallel(). func TestWorktreeCommitPersistence(t *testing.T) { - // Only test auto-commit strategy - it creates commits on the working branch + // Test worktree commit persistence with manual-commit strategy worktreeStrategies := []string{ - strategy.StrategyNameAutoCommit, + strategy.StrategyNameManualCommit, } RunForStrategiesSequential(t, worktreeStrategies, func(t *testing.T, strat string) { diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 9c34d9041..66c5a729c 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -131,12 +131,11 @@ func handleLifecycleTurnStart(ag agent.Agent, event *agent.Event) error { } // Ensure strategy setup and initialize session - strat := GetStrategy() - - if err := strat.EnsureSetup(); err != nil { + if err := strategy.EnsureSetup(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to ensure strategy setup: %v\n", err) } + strat := GetStrategy() if initializer, ok := strat.(strategy.SessionInitializer); ok { agentType := ag.Type() if err := initializer.InitializeSession(sessionID, agentType, event.SessionRef, event.Prompt); err != nil { @@ -219,7 +218,6 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { var allPrompts []string var summary string var modifiedFiles []string - var newTranscriptPosition int // Compute subagents directory for agents that support subagent extraction. // Subagent transcripts live in //subagents/ @@ -247,17 +245,12 @@ func handleLifecycleTurnEnd(ag agent.Agent, event *agent.Event) error { } else { modifiedFiles = files } - // Get position from basic analyzer - if _, pos, posErr := analyzer.ExtractModifiedFilesFromOffset(transcriptRef, transcriptOffset); posErr == nil { - newTranscriptPosition = pos - } } else { // Fall back to basic extraction (main transcript only) - if files, pos, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptRef, transcriptOffset); fileErr != nil { + if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptRef, transcriptOffset); fileErr != nil { fmt.Fprintf(os.Stderr, "Warning: failed to extract modified files: %v\n", fileErr) } else { modifiedFiles = files - newTranscriptPosition = pos } } } @@ -398,11 +391,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 { @@ -621,7 +609,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) @@ -635,29 +623,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 59198da8a..850467d0d 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -33,7 +33,7 @@ const ( SettingsFileName = "settings.json" ) -// MetadataBranchName is the orphan branch used by auto-commit and manual-commit strategies to store metadata +// MetadataBranchName is the orphan branch used by manual-commit strategie to store metadata const MetadataBranchName = "entire/checkpoints/v1" // CheckpointPath returns the sharded storage path for a checkpoint ID. diff --git a/cmd/entire/cli/reset.go b/cmd/entire/cli/reset.go index 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..ee6885aee 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -14,10 +14,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" ) -// DefaultStrategyName is the default strategy when none is configured. -// This is duplicated here to avoid importing the strategy package (which would create a cycle). -const DefaultStrategyName = "manual-commit" - const ( // EntireSettingsFile is the path to the Entire settings file EntireSettingsFile = ".entire/settings.json" @@ -27,8 +23,6 @@ const ( // EntireSettings represents the .entire/settings.json configuration type EntireSettings struct { - // Strategy is the name of the git strategy to use - Strategy string `json:"strategy"` // Enabled indicates whether Entire is active. When false, CLI commands // show a disabled message and hooks exit silently. Defaults to true. @@ -85,8 +79,6 @@ func Load() (*EntireSettings, error) { } } - applyDefaults(settings) - return settings, nil } @@ -101,8 +93,7 @@ func LoadFromFile(filePath string) (*EntireSettings, error) { // Returns default settings if the file doesn't exist. func loadFromFile(filePath string) (*EntireSettings, error) { settings := &EntireSettings{ - Strategy: DefaultStrategyName, - Enabled: true, // Default to enabled + Enabled: true, // Default to enabled } data, err := os.ReadFile(filePath) //nolint:gosec // path is from caller @@ -118,7 +109,6 @@ func loadFromFile(filePath string) (*EntireSettings, error) { if err := dec.Decode(settings); err != nil { return nil, fmt.Errorf("parsing settings file: %w", err) } - applyDefaults(settings) return settings, nil } @@ -140,17 +130,6 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return fmt.Errorf("parsing JSON: %w", err) } - // Override strategy if present and non-empty - if strategyRaw, ok := raw["strategy"]; ok { - var s string - if err := json.Unmarshal(strategyRaw, &s); err != nil { - return fmt.Errorf("parsing strategy field: %w", err) - } - if s != "" { - settings.Strategy = s - } - } - // Override enabled if present if enabledRaw, ok := raw["enabled"]; ok { var e bool @@ -207,12 +186,6 @@ func mergeJSON(settings *EntireSettings, data []byte) error { return nil } -func applyDefaults(settings *EntireSettings) { - if settings.Strategy == "" { - settings.Strategy = DefaultStrategyName - } -} - // 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 { diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index ad09bc57a..1a037fa28 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -19,7 +19,7 @@ func TestLoad_RejectsUnknownKeys(t *testing.T) { // Create settings.json with an unknown key settingsFile := filepath.Join(entireDir, "settings.json") - settingsContent := `{"strategy": "manual-commit", "unknown_key": "value"}` + settingsContent := `{"enabled": true, "unknown_key": "value"}` if err := os.WriteFile(settingsFile, []byte(settingsContent), 0644); err != nil { t.Fatalf("failed to write settings file: %v", err) } @@ -54,7 +54,6 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { // Create settings.json with all valid keys settingsFile := filepath.Join(entireDir, "settings.json") settingsContent := `{ - "strategy": "auto-commit", "enabled": true, "local_dev": false, "log_level": "debug", @@ -80,9 +79,6 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { } // Verify values - if settings.Strategy != "auto-commit" { - t.Errorf("expected strategy 'auto-commit', got %q", settings.Strategy) - } if !settings.Enabled { t.Error("expected enabled to be true") } @@ -106,7 +102,7 @@ func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) { // Create valid settings.json settingsFile := filepath.Join(entireDir, "settings.json") - settingsContent := `{"strategy": "manual-commit"}` + settingsContent := `{"enabled": true}` if err := os.WriteFile(settingsFile, []byte(settingsContent), 0644); err != nil { t.Fatalf("failed to write settings file: %v", err) } diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 80fb2932d..396b0538b 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 @@ -96,21 +74,17 @@ Strategies: manual-commit (default), auto-commit`, printWrongAgentError(cmd.ErrOrStderr(), agentName) return NewSilentError(errors.New("wrong agent name")) } - return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, strategyFlag, localDev, forceHooks, skipPushSessions, telemetry) + return setupAgentHooksNonInteractive(cmd.OutOrStdout(), ag, localDev, forceHooks, skipPushSessions, telemetry) } - // Check if already fully enabled before prompting for agents. - // Only applies to interactive path (no --strategy flag) with no config flags. - if strategyFlag == "" { - hasConfigFlags := forceHooks || skipPushSessions || !telemetry || useLocalSettings || useProjectSettings || localDev - if !hasConfigFlags { - if fullyEnabled, agentDesc, configPath := isFullyEnabled(); fullyEnabled { - w := cmd.OutOrStdout() - fmt.Fprintln(w, "Already enabled. Everything looks good.") - fmt.Fprintln(w) - fmt.Fprintf(w, " Agent: %s\n", agentDesc) - fmt.Fprintf(w, " Config: %s\n", configPath) - return nil - } + hasConfigFlags := forceHooks || skipPushSessions || !telemetry || useLocalSettings || useProjectSettings || localDev + if !hasConfigFlags { + if fullyEnabled, agentDesc, configPath := isFullyEnabled(); fullyEnabled { + w := cmd.OutOrStdout() + fmt.Fprintln(w, "Already enabled. Everything looks good.") + fmt.Fprintln(w) + fmt.Fprintf(w, " Agent: %s\n", agentDesc) + fmt.Fprintf(w, " Config: %s\n", configPath) + return nil } } @@ -120,9 +94,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) }, } @@ -134,14 +105,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() @@ -239,103 +205,6 @@ func isFullyEnabled() (enabled bool, agentDesc string, configPath string) { return true, desc, configDisplay } -// 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) - } - - // 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). // The isFullyEnabled check is handled by the caller before agent detection. @@ -352,9 +221,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 { @@ -362,7 +228,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 @@ -423,12 +288,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) } @@ -498,7 +358,7 @@ func checkDisabledGuard(w io.Writer) bool { // 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()) @@ -690,7 +550,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) @@ -715,7 +575,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 { @@ -730,20 +590,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") != "" { @@ -777,12 +623,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 1d0a820f5..f9b3f6cfd 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..39efe3ffa 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" @@ -126,31 +127,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..13cf26456 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 { 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 9b8f2439d..ed7ffca1a 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 { @@ -310,7 +335,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 @@ -759,73 +784,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 { @@ -1375,7 +1336,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/session.go b/cmd/entire/cli/strategy/session.go index d090f420e..cd6cba113 100644 --- a/cmd/entire/cli/strategy/session.go +++ b/cmd/entire/cli/strategy/session.go @@ -75,7 +75,6 @@ type PromptResponse struct { // This is used by the explain command to display checkpoint content. type CheckpointDetails struct { // Interactions contains all prompt/response pairs in this checkpoint. - // For strategies like auto-commit/commit, this typically has one entry. // For strategies like shadow, this may have multiple entries. Interactions []PromptResponse diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index 188152efd..9cd111e41 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -352,17 +352,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) @@ -371,11 +369,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. @@ -394,7 +387,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 | From 7008ffaa6680ab057904d31f2e06c6689bad6250 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Thu, 19 Feb 2026 11:33:39 +1100 Subject: [PATCH 2/4] fix lint Entire-Checkpoint: 3786a4d9fefd --- cmd/entire/cli/config_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cmd/entire/cli/config_test.go b/cmd/entire/cli/config_test.go index 7debf43bc..f729ec343 100644 --- a/cmd/entire/cli/config_test.go +++ b/cmd/entire/cli/config_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "strings" "testing" - ) const ( @@ -128,7 +127,7 @@ func TestIsEnabled(t *testing.T) { } // Test 3: Settings with enabled: true - should return true - settingsContent = `{"enabled": true}` + settingsContent = testSettingsEnabled if err := os.WriteFile(EntireSettingsFile, []byte(settingsContent), 0o644); err != nil { t.Fatalf("Failed to write settings file: %v", err) } @@ -162,7 +161,7 @@ func TestLoadEntireSettings_LocalOverridesStrategy(t *testing.T) { t.Fatalf("Failed to write settings file: %v", err) } - localSettings := `{"enabled": true}` + localSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } @@ -253,7 +252,7 @@ func TestLoadEntireSettings_OnlyLocalFileExists(t *testing.T) { setupLocalOverrideTestDir(t) // No base settings file - localSettings := `{"enabled": true}` + localSettings := testSettingsEnabled if err := os.WriteFile(EntireSettingsLocalFile, []byte(localSettings), 0o644); err != nil { t.Fatalf("Failed to write local settings file: %v", err) } From 6d56d6ca2a30e4e0bf396623867e2bf377fb4de5 Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Thu, 19 Feb 2026 12:21:12 +1100 Subject: [PATCH 3/4] fix test Entire-Checkpoint: 68f26bfdf530 --- .../cli/integration_test/resume_test.go | 40 +++++- .../cli/integration_test/setup_cmd_test.go | 24 ++-- cmd/entire/cli/integration_test/testenv.go | 6 +- .../cli/integration_test/worktree_test.go | 126 ------------------ cmd/entire/cli/paths/paths.go | 2 +- 5 files changed, 52 insertions(+), 146 deletions(-) diff --git a/cmd/entire/cli/integration_test/resume_test.go b/cmd/entire/cli/integration_test/resume_test.go index b7b30a950..6459a02e1 100644 --- a/cmd/entire/cli/integration_test/resume_test.go +++ b/cmd/entire/cli/integration_test/resume_test.go @@ -43,6 +43,9 @@ func TestResume_SwitchBranchWithSession(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create a hello script", "hello.rb") + // Remember the feature branch name featureBranch := env.GetCurrentBranch() @@ -105,6 +108,9 @@ func TestResume_AlreadyOnBranch(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create a test script", "test.js") + currentBranch := env.GetCurrentBranch() // Run resume on the branch we're already on @@ -232,6 +238,9 @@ func TestResume_SessionLogAlreadyExists(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Pre-create a session log in Claude project dir with different content @@ -313,6 +322,9 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { t.Fatalf("SimulateStop session2 failed: %v", err) } + // Commit the sessions' changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Update to version 2", "file.txt") + featureBranch := env.GetCurrentBranch() // Switch to main @@ -324,8 +336,8 @@ func TestResume_MultipleSessionsOnBranch(t *testing.T) { t.Fatalf("resume failed: %v\nOutput: %s", err, output) } - // Should show session info (from the most recent session) - if !strings.Contains(output, "Session:") { + // Should show session info (multi-session output says "Restored N sessions") + if !strings.Contains(output, "Restored 2 sessions") && !strings.Contains(output, "Session:") { t.Errorf("output should contain session info, got: %s", output) } @@ -358,6 +370,9 @@ func TestResume_CheckpointWithoutMetadata(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create real file", "real.txt") + // Create a new branch for the orphan checkpoint test env.GitCheckoutNewBranch("feature/orphan-checkpoint") @@ -416,6 +431,9 @@ func TestResume_AfterMergingMain(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create a hello script", "hello.rb") + // Remember the feature branch name featureBranch := env.GetCurrentBranch() @@ -592,6 +610,9 @@ func TestResume_LocalLogNewerTimestamp_RequiresForce(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -650,6 +671,9 @@ func TestResume_LocalLogNewerTimestamp_ForceOverwrites(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -709,6 +733,9 @@ func TestResume_LocalLogNewerTimestamp_UserConfirmsOverwrite(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -773,6 +800,9 @@ func TestResume_LocalLogNewerTimestamp_UserDeclinesOverwrite(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with a NEWER timestamp than the checkpoint @@ -838,6 +868,9 @@ func TestResume_CheckpointNewerTimestamp(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log with an OLDER timestamp than the checkpoint @@ -1004,6 +1037,9 @@ func TestResume_LocalLogNoTimestamp(t *testing.T) { t.Fatalf("SimulateStop failed: %v", err) } + // Commit the session's changes (manual-commit requires user to commit) + env.GitCommitWithShadowHooks("Create hello method", "hello.rb") + featureBranch := env.GetCurrentBranch() // Create a local log WITHOUT a valid timestamp (can't be parsed) diff --git a/cmd/entire/cli/integration_test/setup_cmd_test.go b/cmd/entire/cli/integration_test/setup_cmd_test.go index f964574c8..ef612b1c4 100644 --- a/cmd/entire/cli/integration_test/setup_cmd_test.go +++ b/cmd/entire/cli/integration_test/setup_cmd_test.go @@ -72,8 +72,8 @@ func TestEnableDisable(t *testing.T) { t.Errorf("Expected status to show 'Enabled', got: %s", stdout) } - // Disable - stdout = env.RunCLI("disable") + // Disable (using --project so re-enable can override cleanly) + stdout = env.RunCLI("disable", "--project") if !strings.Contains(stdout, "disabled") { t.Errorf("Expected disable output to contain 'disabled', got: %s", stdout) } @@ -84,8 +84,8 @@ func TestEnableDisable(t *testing.T) { t.Errorf("Expected status to show 'Disabled', got: %s", stdout) } - // Re-enable (using --strategy flag for non-interactive mode) - stdout = env.RunCLI("enable", "--strategy", strategyName) + // Re-enable (using --agent for non-interactive mode) + stdout = env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") if !strings.Contains(stdout, "Ready.") { t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) } @@ -161,8 +161,8 @@ func TestEnableWhenDisabled(t *testing.T) { // Disable Entire env.SetEnabled(false) - // Enable command should work (using --strategy flag for non-interactive mode) - stdout := env.RunCLI("enable", "--strategy", strategyName) + // Enable command should work (using --agent for non-interactive mode) + stdout := env.RunCLI("enable", "--agent", "claude-code", "--telemetry=false") if !strings.Contains(stdout, "Ready.") { t.Errorf("Expected enable output to contain 'Ready.', got: %s", stdout) } @@ -192,7 +192,7 @@ func TestEnableDefaultStrategy(t *testing.T) { t.Errorf("Expected output to contain 'Ready.', got: %s", stdout) } - // Verify settings file has manual-commit strategy + // Verify settings file exists and has enabled field settingsPath := filepath.Join(env.RepoDir, ".entire", paths.SettingsFileName) data, err := os.ReadFile(settingsPath) if err != nil { @@ -204,16 +204,16 @@ func TestEnableDefaultStrategy(t *testing.T) { t.Fatalf("Failed to parse settings: %v", err) } - strategy, ok := settings["strategy"].(string) + enabled, ok := settings["enabled"].(bool) if !ok { - t.Fatalf("Strategy not found in settings: %v", settings) + t.Fatalf("Enabled not found in settings: %v", settings) } - if strategy != "manual-commit" { - t.Errorf("Expected default strategy to be 'manual-commit', got: %s", strategy) + if !enabled { + t.Error("Expected enabled to be true") } - // Also verify via status command + // Verify status shows manual-commit (the only strategy) stdout = env.RunCLI("status") if !strings.Contains(stdout, "manual-commit") { t.Errorf("Expected status to show 'manual-commit', got: %s", stdout) diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index 55e01741e..8f7ad272b 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -320,13 +320,9 @@ func (env *TestEnv) initEntireInternal(strategyName string, agentName agent.Agen // Write settings.json 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 } - // Only add agent if specified (otherwise defaults to claude-code) - if agentName != "" { - settings["agent"] = string(agentName) - } if strategyOptions != nil { settings["strategy_options"] = strategyOptions } diff --git a/cmd/entire/cli/integration_test/worktree_test.go b/cmd/entire/cli/integration_test/worktree_test.go index 2a3f62145..c44bd4f39 100644 --- a/cmd/entire/cli/integration_test/worktree_test.go +++ b/cmd/entire/cli/integration_test/worktree_test.go @@ -6,138 +6,12 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" - "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/go-git/go-git/v5/plumbing" ) -// TestWorktreeCommitPersistence verifies that commits made via go-git -// in a linked worktree are actually persisted and visible to git CLI. -// -// This is a regression test for the EnableDotGitCommonDir fix. -// Without that fix, go-git commits silently fail in worktrees. -// -// NOTE: This test uses os.Chdir() so it cannot use t.Parallel(). -func TestWorktreeCommitPersistence(t *testing.T) { - // Test worktree commit persistence with manual-commit strategy - worktreeStrategies := []string{ - strategy.StrategyNameManualCommit, - } - - RunForStrategiesSequential(t, worktreeStrategies, func(t *testing.T, strat string) { - env := NewTestEnv(t) - env.InitRepo() - env.InitEntire(strat) - - env.WriteFile("README.md", "# Main Repo") - env.GitAdd("README.md") - env.GitCommit("Initial commit") - - // Create a worktree - worktreeDir := filepath.Join(t.TempDir(), "worktree") - if resolved, err := filepath.EvalSymlinks(filepath.Dir(worktreeDir)); err == nil { - worktreeDir = filepath.Join(resolved, "worktree") - } - - cmd := exec.Command("git", "worktree", "add", worktreeDir, "-b", "worktree-branch") - cmd.Dir = env.RepoDir - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to create worktree: %v\nOutput: %s", err, output) - } - - // Initialize .entire in worktree - worktreeEntireDir := filepath.Join(worktreeDir, ".entire") - if err := os.MkdirAll(worktreeEntireDir, 0o755); err != nil { - t.Fatalf("failed to create .entire in worktree: %v", err) - } - settingsSrc := filepath.Join(env.RepoDir, ".entire", paths.SettingsFileName) - settingsDst := filepath.Join(worktreeEntireDir, paths.SettingsFileName) - settingsData, err := os.ReadFile(settingsSrc) - if err != nil { - t.Fatalf("failed to read settings: %v", err) - } - if err := os.WriteFile(settingsDst, settingsData, 0o644); err != nil { - t.Fatalf("failed to write settings to worktree: %v", err) - } - if err := os.MkdirAll(filepath.Join(worktreeEntireDir, "tmp"), 0o755); err != nil { - t.Fatalf("failed to create tmp dir: %v", err) - } - - // Change to worktree directory - originalWd, _ := os.Getwd() - if err := os.Chdir(worktreeDir); err != nil { - t.Fatalf("failed to chdir to worktree: %v", err) - } - t.Cleanup(func() { - _ = os.Chdir(originalWd) - }) - - // Create a file in the worktree - testFile := filepath.Join(worktreeDir, "worktree-file.txt") - if err := os.WriteFile(testFile, []byte("worktree content"), 0o644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - // Create a HookRunner pointing to the worktree - runner := NewHookRunner(worktreeDir, env.ClaudeProjectDir, t) - - // Simulate a session that creates a commit - sessionID := "worktree-test-session" - transcriptPath := filepath.Join(worktreeEntireDir, "tmp", sessionID+".jsonl") - - builder := NewTranscriptBuilder() - builder.AddUserMessage("Add worktree file") - builder.AddAssistantMessage("I'll add the file.") - toolID := builder.AddToolUse("mcp__acp__Write", "worktree-file.txt", "worktree content") - builder.AddToolResult(toolID) - builder.AddAssistantMessage("Done!") - if err := builder.WriteToFile(transcriptPath); err != nil { - t.Fatalf("failed to write transcript: %v", err) - } - - if err := runner.SimulateUserPromptSubmit(sessionID); err != nil { - t.Fatalf("SimulateUserPromptSubmit failed: %v", err) - } - - if err := runner.SimulateStop(sessionID, transcriptPath); err != nil { - t.Fatalf("SimulateStop failed: %v", err) - } - - // CRITICAL: Verify commit persisted using git CLI (not go-git) - gitLogCmd := exec.Command("git", "log", "--oneline", "-5") - gitLogCmd.Dir = worktreeDir - logOutput, err := gitLogCmd.CombinedOutput() - if err != nil { - t.Fatalf("git log failed: %v\nOutput: %s", err, logOutput) - } - - logLines := strings.Split(strings.TrimSpace(string(logOutput)), "\n") - if len(logLines) < 2 { - t.Errorf("expected at least 2 commits (initial + session), got %d:\n%s", - len(logLines), logOutput) - } - - // Verify git status shows clean working tree - gitStatusCmd := exec.Command("git", "status", "--porcelain") - gitStatusCmd.Dir = worktreeDir - statusOutput, err := gitStatusCmd.CombinedOutput() - if err != nil { - t.Fatalf("git status failed: %v\nOutput: %s", err, statusOutput) - } - - if strings.Contains(string(statusOutput), "worktree-file.txt") { - t.Errorf("worktree-file.txt still appears in git status (commit didn't persist):\n%s", - statusOutput) - } - - t.Logf("Worktree commit test passed for strategy %s", strat) - t.Logf("Git log:\n%s", logOutput) - }) -} - // TestWorktreeOpenRepository verifies that OpenRepository() works correctly // in a worktree context by checking it can read HEAD and refs. // diff --git a/cmd/entire/cli/paths/paths.go b/cmd/entire/cli/paths/paths.go index 850467d0d..8f6d51791 100644 --- a/cmd/entire/cli/paths/paths.go +++ b/cmd/entire/cli/paths/paths.go @@ -33,7 +33,7 @@ const ( SettingsFileName = "settings.json" ) -// MetadataBranchName is the orphan branch used by manual-commit strategie to store metadata +// MetadataBranchName is the orphan branch used by manual-commit strategy to store metadata const MetadataBranchName = "entire/checkpoints/v1" // CheckpointPath returns the sharded storage path for a checkpoint ID. From ebb67d6a29d8bb450baa31a44fee468eef8b046c Mon Sep 17 00:00:00 2001 From: Victor Gutierrez Calderon Date: Thu, 19 Feb 2026 13:22:55 +1100 Subject: [PATCH 4/4] status and doctor display warnings if strategy is found Entire-Checkpoint: d4a45cb7c16a --- cmd/entire/cli/doctor.go | 4 ++ cmd/entire/cli/doctor_test.go | 33 ++++++++++++ cmd/entire/cli/settings/settings.go | 34 ++++++++++++ cmd/entire/cli/settings/settings_test.go | 69 ++++++++++++++++++++++++ cmd/entire/cli/status.go | 8 +-- cmd/entire/cli/status_test.go | 48 +++++++++++++++++ 6 files changed, 193 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index 1b5a0a940..e82c7063f 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/huh" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/strategy" "github.com/go-git/go-git/v5" @@ -59,6 +60,9 @@ type stuckSession struct { } func runSessionsFix(cmd *cobra.Command, force bool) error { + w := cmd.OutOrStdout() + defer func() { settings.WriteDeprecatedStrategyWarnings(w) }() + // Load all session states states, err := strategy.ListSessionStates() if err != nil { diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index 152c6dc76..b1383208e 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -1,6 +1,8 @@ package cli import ( + "bytes" + "strings" "testing" "time" @@ -11,6 +13,7 @@ import ( "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -286,3 +289,33 @@ func TestClassifySession_WorktreeIDInShadowBranch(t *testing.T) { expectedBranch := checkpoint.ShadowBranchNameForCommit(baseCommit, worktreeID) assert.Equal(t, expectedBranch, result.ShadowBranch) } + +func TestRunSessionsFix_DeprecatedStrategyWarning(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + writeSettings(t, `{"enabled": true, "strategy": "auto-commit"}`) + + var stdout bytes.Buffer + cmd := &cobra.Command{Use: "doctor"} + cmd.SetOut(&stdout) + + // runSessionsFix should show warning after "No stuck sessions found." + err := runSessionsFix(cmd, false) + require.NoError(t, err) + + output := stdout.String() + if !strings.Contains(output, "no longer needed") { + t.Errorf("Expected deprecation warning, got: %s", output) + } + if !strings.Contains(output, "strategy") { + t.Errorf("Expected warning to mention 'strategy', got: %s", output) + } + + // Warning should appear after the main output + noStuckIdx := strings.Index(output, "No stuck sessions found.") + warningIdx := strings.Index(output, "no longer needed") + if noStuckIdx >= warningIdx { + t.Errorf("Expected warning after main output, got: %s", output) + } +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index ee6885aee..0cc65c9ec 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" @@ -43,6 +44,10 @@ type EntireSettings struct { // Telemetry controls anonymous usage analytics. // nil = not asked yet (show prompt), true = opted in, false = opted out Telemetry *bool `json:"telemetry,omitempty"` + + // Deprecated: no longer used. Exists to tolerate old settings files + // that still contain "strategy": "auto-commit" or similar. + Strategy string `json:"strategy,omitempty"` } // Load loads the Entire settings from .entire/settings.json, @@ -228,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.\n", "strategy", f) + } + return len(files) > 0 +} + // Save saves the settings to .entire/settings.json. func Save(settings *EntireSettings) error { return saveToFile(settings, EntireSettingsFile) diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index 1a037fa28..9d4cf891a 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -131,6 +131,75 @@ func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) { } } +func TestLoad_AcceptsDeprecatedStrategyField(t *testing.T) { + tmpDir := t.TempDir() + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } + + settingsFile := filepath.Join(entireDir, "settings.json") + if err := os.WriteFile(settingsFile, []byte(`{"enabled": true, "strategy": "auto-commit"}`), 0o644); err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + + t.Chdir(tmpDir) + + s, err := Load() + if err != nil { + t.Fatalf("expected no error for deprecated strategy field, got: %v", err) + } + if s.Strategy != "auto-commit" { + t.Errorf("expected strategy 'auto-commit', got %q", s.Strategy) + } +} + +func TestFilesWithDeprecatedStrategy(t *testing.T) { + tmpDir := t.TempDir() + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + + t.Chdir(tmpDir) + + // No strategy field → empty result + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled": true}`), 0o644); err != nil { + t.Fatal(err) + } + if files := FilesWithDeprecatedStrategy(); len(files) != 0 { + t.Errorf("expected no deprecated files, got %v", files) + } + + // Add strategy to project settings + if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled": true, "strategy": "auto-commit"}`), 0o644); err != nil { + t.Fatal(err) + } + files := FilesWithDeprecatedStrategy() + if len(files) != 1 || files[0] != EntireSettingsFile { + t.Errorf("expected [%s], got %v", EntireSettingsFile, files) + } + + // Also add strategy to local settings + if err := os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(`{"strategy": "manual-commit"}`), 0o644); err != nil { + t.Fatal(err) + } + files = FilesWithDeprecatedStrategy() + if len(files) != 2 { + t.Errorf("expected 2 deprecated files, got %v", files) + } +} + // containsUnknownField checks if the error message indicates an unknown field func containsUnknownField(msg string) bool { // Go's json package reports unknown fields with this message format diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index 39efe3ffa..d998cc6e0 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -77,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) } @@ -99,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 diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 13cf26456..84eda2ca7 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -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