Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
533 changes: 533 additions & 0 deletions cmd/entire/cli/settings/load_v2.go

Large diffs are not rendered by default.

1,143 changes: 1,143 additions & 0 deletions cmd/entire/cli/settings/load_v2_test.go

Large diffs are not rendered by default.

214 changes: 214 additions & 0 deletions cmd/entire/cli/settings/schema_v2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Schema-v2 settings types. See synthesizeFromLegacy in load_v2.go for
// the v1→v2 mapping.

package settings

import (
"errors"
"fmt"
"sort"
"strings"
)

// CurrentSchemaVersion is the schema version emitted by v2 writers.
// Files marked with this version are parsed via the v2 path; older files
// are loaded via the legacy parser and synthesized into Settings on the fly.
const CurrentSchemaVersion = 2

// Settings is the schema-v2 representation of .entire/settings.json.
//
// All fields are JSON-omitempty where defaults are well-defined so that
// hand-written settings files stay minimal. Pointer types are used where
// "unset" must be distinguishable from "explicit false" (e.g. Telemetry,
// SignCommits, PushSessions).
type Settings struct {
// Schema identifies the settings file schema version. Always 2 in this struct.
Schema int `json:"schema"`

// Enabled indicates whether Entire is active. When false, CLI commands
// show a disabled message and hooks exit silently. Defaults to true.
Enabled bool `json:"enabled"`

// LocalDev indicates whether to use "go run" instead of the "entire"
// binary. Used during development when the binary is not installed.
LocalDev bool `json:"local_dev,omitempty"`

// Logging configures runtime logging.
Logging LoggingConfig `json:"logging,omitempty"`

// Checkpoints configures permanent checkpoint storage and related ops.
Checkpoints CheckpointsConfig `json:"checkpoints,omitempty"`

// Hooks configures git hook behavior.
Hooks HooksConfig `json:"hooks,omitempty"`

// Features toggles optional behaviors.
Features FeaturesConfig `json:"features,omitempty"`

// Redaction configures PII redaction beyond the default secret detection.
Redaction *RedactionSettings `json:"redaction,omitempty"`

// Telemetry controls anonymous usage analytics.
// nil = not asked yet, true = opted in, false = opted out.
Telemetry *bool `json:"telemetry,omitempty"`

// SummaryGeneration configures provider selection and timeout for
// `entire explain --generate`.
SummaryGeneration *SummaryGenerationConfig `json:"summary_generation,omitempty"`
}

// LoggingConfig configures runtime logging verbosity.
type LoggingConfig struct {
// Level is the logging verbosity (debug, info, warn, error).
// Can be overridden by ENTIRE_LOG_LEVEL. Defaults to "info" when unset.
Level string `json:"level,omitempty"`
}

// CheckpointsConfig configures checkpoint storage and related operations.
//
// Primary serves all reads and is the authoritative writer. Mirrors receive
// best-effort fan-out writes (warn on failure, never serve reads). This
// shape replaces the legacy strategy_options{checkpoints_version, gmeta, ...}
// soup with explicit, typed selection.
type CheckpointsConfig struct {
// Primary is the authoritative checkpoint backend.
// Reads always come from Primary. Writes that fail here are fatal.
Primary BackendConfig `json:"primary"`

// Mirrors are best-effort write targets.
// Mirror failures are logged but do not fail the operation. Mirrors
// never serve reads — they are export targets, not sources of truth.
Mirrors []BackendConfig `json:"mirrors,omitempty"`

// Git configures the destination for git-based backends (v1, v2, gmeta).
// Optional; when unset, the default origin is used. Per-backend overrides
// can be added by giving BackendConfig its own Git field if a future use
// case needs different destinations for different git backends — today
// this single value applies to all of them.
Git *CheckpointRemoteConfig `json:"git,omitempty"`

// FullTranscriptRetentionDays is the retention window (in days) for
// archived raw-transcript generations. Zero/negative falls back to
// the documented default (60 days).
FullTranscriptRetentionDays int `json:"full_transcript_retention_days,omitempty"`

// SignCommits controls whether checkpoint commits are signed.
// nil/true = sign (default), false = skip signing.
SignCommits *bool `json:"sign_commits,omitempty"`

// FilteredFetches enables --filter=blob:none on checkpoint fetches.
FilteredFetches bool `json:"filtered_fetches,omitempty"`

// PushSessions controls whether session refs are pushed to remotes.
// nil = push (default), explicit false = do not push.
PushSessions *bool `json:"push_sessions,omitempty"`
}

// BackendConfig identifies and configures a single checkpoint backend.
//
// The Type field selects the backend implementation (e.g. "v1", "v2",
// "gmeta"). Backend-specific configuration is added as additional fields
// here as backends are introduced (e.g. an "s3" backend would carry
// bucket/region; today every supported backend needs only Type).
type BackendConfig struct {
// Type is the backend identifier. Recognized values: "v1", "v2", "gmeta".
Type string `json:"type"`
}

// HooksConfig configures git hook behavior.
type HooksConfig struct {
// CommitLinking controls how commits are linked to agent sessions.
// "always" = auto-link without prompting, "prompt" = ask each commit.
// Defaults to "prompt" when unset.
CommitLinking string `json:"commit_linking,omitempty"`

// AbsoluteGitHookPath embeds the full binary path in git hooks instead
// of bare "entire". Needed for GUI git clients (Xcode, Tower, etc.)
// that don't source shell profiles and can't find "entire" on PATH.
AbsoluteGitHookPath bool `json:"absolute_git_hook_path,omitempty"`
}

// FeaturesConfig toggles optional product behaviors.
type FeaturesConfig struct {
// Summarize enables AI-generated checkpoint summaries.
Summarize bool `json:"summarize,omitempty"`

// ExternalAgents enables discovery and registration of external agent
// plugins (entire-agent-* binaries on $PATH).
ExternalAgents bool `json:"external_agents,omitempty"`

// Vercel marks the repository as using Vercel; the metadata branch
// then includes a vercel.json that disables deployments for Entire branches.
Vercel bool `json:"vercel,omitempty"`
}

// SummaryGenerationConfig configures `entire explain --generate`.
//
// Replaces legacy SummaryGenerationSettings + the top-level
// SummaryTimeoutSeconds field, grouping all summary-generation knobs.
type SummaryGenerationConfig struct {
// Provider is the agent name for summary generation
// (e.g. "claude-code", "codex", "gemini").
Provider string `json:"provider,omitempty"`

// Model is an optional model hint passed to the selected provider.
Model string `json:"model,omitempty"`

// TimeoutSeconds is an optional hard deadline for summary generation.
// Zero or negative means "unset" — the caller picks the default.
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
}

// knownBackendTypes is the set of backend type identifiers Validate accepts.
// Kept here next to BackendConfig so adding a new backend type is a single
// place to update — Validate's error messages format from this map.
var knownBackendTypes = map[string]struct{}{
BackendTypeV1: {},
BackendTypeV2: {},
BackendTypeGmeta: {},
}

// knownBackendTypesList returns the backend types in sorted order for use
// in error messages. Sorting keeps test assertions deterministic.
func knownBackendTypesList() string {
names := make([]string, 0, len(knownBackendTypes))
for n := range knownBackendTypes {
names = append(names, n)
}
sort.Strings(names)
return strings.Join(names, ", ")
}

// Validate checks the Settings for semantic correctness beyond what JSON
// decoding catches. Run this after parsing or merging to surface invalid
// configurations at load time rather than at first use.
//
// Current rules:
// - Schema must be exactly CurrentSchemaVersion. Writers emit only the
// current value, and Validate rejects others. (isSchemaV2 accepts >=
// as a shape probe, but strict decode plus this check enforce the
// actual contract.)
// - Checkpoints.Primary.Type must be a known backend.
// - Each Mirror's Type must be a known backend.
// - SummaryGeneration.Model requires SummaryGeneration.Provider, matching
// the legacy SummaryGenerationSettings.Validate semantics.
func (s *Settings) Validate() error {
if s == nil {
return errors.New("settings: nil")
}
if s.Schema != CurrentSchemaVersion {
return fmt.Errorf("settings: schema = %d, want %d", s.Schema, CurrentSchemaVersion)
}
if _, ok := knownBackendTypes[s.Checkpoints.Primary.Type]; !ok {
return fmt.Errorf("checkpoints.primary.type = %q: must be one of %s", s.Checkpoints.Primary.Type, knownBackendTypesList())
}
for i, m := range s.Checkpoints.Mirrors {
if _, ok := knownBackendTypes[m.Type]; !ok {
return fmt.Errorf("checkpoints.mirrors[%d].type = %q: must be one of %s", i, m.Type, knownBackendTypesList())
}
Comment thread
Soph marked this conversation as resolved.
}
if s.SummaryGeneration != nil && s.SummaryGeneration.Model != "" && s.SummaryGeneration.Provider == "" {
return fmt.Errorf("summary_generation.model %q set without summary_generation.provider", s.SummaryGeneration.Model)
}
return nil
}
16 changes: 8 additions & 8 deletions cmd/entire/cli/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,11 @@ func loadMergedSettings(settingsFileAbs, localSettingsFileAbs string) (*EntireSe
}

// Apply local overrides if they exist
localData, err := os.ReadFile(localSettingsFileAbs) //nolint:gosec // path is from AbsPath or constant
localData, err := readSettingsFileIfExists(localSettingsFileAbs)
if err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("reading local settings file: %w", err)
}
// Local file doesn't exist, continue without overrides
} else {
return nil, fmt.Errorf("reading local settings file: %w", err)
}
if localData != nil {
if err := mergeJSON(settings, localData); err != nil {
return nil, fmt.Errorf("merging local settings: %w", err)
}
Expand Down Expand Up @@ -684,9 +682,11 @@ func (s *EntireSettings) IsSummarizeEnabled() bool {

// CheckpointRemoteConfig holds the structured checkpoint remote configuration.
// Stored in strategy_options.checkpoint_remote as {"provider": "github", "repo": "org/repo"}.
// JSON tags are present so the same type can be serialized directly under
// the schema-v2 Settings.Checkpoints.Remote field.
type CheckpointRemoteConfig struct {
Provider string // e.g., "github"
Repo string // e.g., "org/checkpoints-repo"
Provider string `json:"provider"` // e.g., "github"
Repo string `json:"repo"` // e.g., "org/checkpoints-repo"
}

// Owner returns the owner portion of the repo field (before the slash).
Expand Down
10 changes: 5 additions & 5 deletions cmd/entire/cli/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ func TestLoad_AcceptsValidKeys(t *testing.T) {
if settings.SummaryGeneration.Provider != "claude-code" {
t.Errorf("expected summary_generation.provider 'claude-code', got %q", settings.SummaryGeneration.Provider)
}
if settings.SummaryGeneration.Model != "sonnet" { //nolint:goconst // test literal
if settings.SummaryGeneration.Model != modelSonnet {
t.Errorf("expected summary_generation.model 'sonnet', got %q", settings.SummaryGeneration.Model)
}
if settings.Redaction == nil {
Expand Down Expand Up @@ -583,7 +583,7 @@ func TestLoadFromFile_AcceptsModelWithoutProvider(t *testing.T) {
if err != nil {
t.Fatalf("LoadFromFile should accept model-only file, got error: %v", err)
}
if s.SummaryGeneration == nil || s.SummaryGeneration.Model != "sonnet" {
if s.SummaryGeneration == nil || s.SummaryGeneration.Model != modelSonnet {
t.Fatalf("expected model 'sonnet', got %+v", s.SummaryGeneration)
}
}
Expand All @@ -597,8 +597,8 @@ func TestSummaryGenerationSettings_Validate(t *testing.T) {
wantErr bool
}{
{name: "nil receiver is valid", s: nil, wantErr: false},
{name: "provider and model is valid", s: &SummaryGenerationSettings{Provider: "claude-code", Model: "sonnet"}, wantErr: false},
{name: "model without provider is invalid", s: &SummaryGenerationSettings{Model: "sonnet"}, wantErr: true},
{name: "provider and model is valid", s: &SummaryGenerationSettings{Provider: "claude-code", Model: modelSonnet}, wantErr: false},
{name: "model without provider is invalid", s: &SummaryGenerationSettings{Model: modelSonnet}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -660,7 +660,7 @@ func TestMergeJSON_SummaryGeneration_SameProviderPreservesModel(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.SummaryGeneration.Provider != "claude-code" || s.SummaryGeneration.Model != "sonnet" {
if s.SummaryGeneration.Provider != "claude-code" || s.SummaryGeneration.Model != modelSonnet {
t.Errorf("Provider/Model = %q/%q, want claude-code/sonnet", s.SummaryGeneration.Provider, s.SummaryGeneration.Model)
}
}
Expand Down
24 changes: 24 additions & 0 deletions cmd/entire/cli/settings/testdata/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Settings testdata

Canonical example settings.json files. Loaded by `TestLoadV2_TestdataExamples`
in `load_v2_test.go`; doubles as hand-readable documentation of the v2 shape.

## Files

| File | Description |
| --- | --- |
| `v2-minimal.json` | Smallest meaningful schema-v2 file: `schema` + a primary backend. |
| `v2-with-gmeta-mirror.json` | Primary v2 + a gmeta mirror (write-only fan-out). |
| `v2-with-git-config.json` | Primary v2 + a git destination override (separate checkpoint repo). |
| `v2-kitchen-sink.json` | Every documented field populated — useful as a reference. |
| `legacy-equivalent.json` | Legacy-shape settings that synthesizes to the same struct as `v2-kitchen-sink.json`. |

## Migration mapping

`legacy-equivalent.json` and `v2-kitchen-sink.json` are paired: loading either
through `LoadV2FromBytes` produces an identical `*Settings` value (modulo
defaults that round-trip correctly). The pairing is the most concrete way
to read the legacy → v2 mapping.

When adding a new legacy field that the synthesizer should translate, add it
to both files and the equivalence test confirms the round-trip.
33 changes: 33 additions & 0 deletions cmd/entire/cli/settings/testdata/legacy-equivalent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"enabled": true,
"log_level": "debug",
"commit_linking": "always",
"external_agents": true,
"telemetry": true,
"sign_checkpoint_commits": true,
"summary_timeout_seconds": 60,
"summary_generation": {
"provider": "claude-code",
"model": "sonnet"
},
"redaction": {
"pii": {
"enabled": true,
"email": true,
"phone": true,
"address": false
}
},
"strategy_options": {
"checkpoints_version": 2,
"gmeta": true,
"checkpoint_remote": {
"provider": "github",
"repo": "myorg/myrepo-checkpoints"
},
"summarize": {"enabled": true},
"filtered_fetches": true,
"push_sessions": true,
"full_transcript_generation_retention_days": 90
}
}
42 changes: 42 additions & 0 deletions cmd/entire/cli/settings/testdata/v2-kitchen-sink.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"schema": 2,
"enabled": true,
"logging": {
"level": "debug"
},
"checkpoints": {
"primary": {"type": "v2"},
"mirrors": [
{"type": "gmeta"}
],
"git": {
"provider": "github",
"repo": "myorg/myrepo-checkpoints"
},
"full_transcript_retention_days": 90,
"sign_commits": true,
"filtered_fetches": true,
"push_sessions": true
},
"hooks": {
"commit_linking": "always"
},
"features": {
"summarize": true,
"external_agents": true
},
"redaction": {
"pii": {
"enabled": true,
"email": true,
"phone": true,
"address": false
}
},
"telemetry": true,
"summary_generation": {
"provider": "claude-code",
"model": "sonnet",
"timeout_seconds": 60
}
}
6 changes: 6 additions & 0 deletions cmd/entire/cli/settings/testdata/v2-minimal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"schema": 2,
"checkpoints": {
"primary": {"type": "v2"}
}
}
Loading
Loading