Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
41c48af
Add handling for Vercel apps to exclude entire branches from deployme…
ashtom Apr 9, 2026
77a12f2
Update cmd/entire/cli/setup.go
ashtom Apr 9, 2026
fa28def
Update cmd/entire/cli/setup.go
ashtom Apr 9, 2026
fcf3459
Handle non-NotExist os.Stat errors in Vercel detection and fix ignore…
Copilot Apr 9, 2026
570726a
Change behavior to create vercel.json in Entire metadata branch.
ashtom Apr 12, 2026
d25d4da
Merge branch 'main' into ashtom/vercel-config
ashtom Apr 13, 2026
8569a40
Fix linter error.
ashtom Apr 14, 2026
e1cefae
Fix more linter errors.
ashtom Apr 14, 2026
62631bf
Address Copilot feedback to consider --local for vercel config.
ashtom Apr 14, 2026
9932710
Potential fix for pull request finding
ashtom Apr 14, 2026
c95946e
Merge branch 'main' into ashtom/vercel-config
ashtom Apr 14, 2026
23f9111
Deduplicate metadata branch vercel config merge
ashtom Apr 14, 2026
a791683
Optimize config loading for Vercel.
ashtom Apr 14, 2026
ca39df9
Clean up settings target file parameter.
ashtom Apr 14, 2026
eb4b5f4
Clean up test seam.
ashtom Apr 14, 2026
11c911e
Clean up load method.
ashtom Apr 14, 2026
b11dab3
Move Vercel helper methods into one file.
ashtom Apr 14, 2026
3f5893f
Fix missing target file.
ashtom Apr 14, 2026
60d2bb7
Merge remote-tracking branch 'origin/main' into ashtom/vercel-config
Copilot Apr 14, 2026
79cdfba
Improve enable/config messages for Vercel setting
ashtom Apr 15, 2026
5bf04bc
Fix vercel config lint issues
ashtom Apr 15, 2026
42f2803
Merge branch 'main' into ashtom/vercel-config
ashtom Apr 15, 2026
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
154 changes: 154 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import (
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/cmd/entire/cli/trailers"
"github.com/entireio/cli/cmd/entire/cli/vercelconfig"
"github.com/entireio/cli/cmd/entire/cli/versioninfo"
"github.com/entireio/cli/redact"
"github.com/stretchr/testify/require"

"github.com/go-git/go-git/v6"
"github.com/go-git/go-git/v6/config"
"github.com/go-git/go-git/v6/plumbing"
"github.com/go-git/go-git/v6/plumbing/filemode"
"github.com/go-git/go-git/v6/plumbing/object"
)

Expand Down Expand Up @@ -448,6 +450,158 @@ func setupBranchTestRepo(t *testing.T) (*git.Repository, plumbing.Hash) {
return repo, commitHash
}

func TestEnsureSessionsBranch_WritesVercelConfigWhenEnabled(t *testing.T) {
repo, _ := setupBranchTestRepo(t)
worktree, err := repo.Worktree()
if err != nil {
t.Fatalf("repo.Worktree() error = %v", err)
}

entireDir := filepath.Join(worktree.Filesystem.Root(), ".entire")
if err := os.MkdirAll(entireDir, 0o755); err != nil {
t.Fatalf("mkdir .entire: %v", err)
}
if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled":true,"vercel":true}`), 0o644); err != nil {
t.Fatalf("write settings.json: %v", err)
}

store := NewGitStore(repo)
if err := store.ensureSessionsBranch(context.Background()); err != nil {
t.Fatalf("ensureSessionsBranch() error = %v", err)
}

ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
if err != nil {
t.Fatalf("metadata branch ref: %v", err)
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
t.Fatalf("metadata commit: %v", err)
}
tree, err := commit.Tree()
if err != nil {
t.Fatalf("metadata tree: %v", err)
}
file, err := tree.File(vercelconfig.FileName)
if err != nil {
t.Fatalf("expected %s on metadata branch: %v", vercelconfig.FileName, err)
}
content, err := file.Contents()
if err != nil {
t.Fatalf("read %s: %v", vercelconfig.FileName, err)
}

var config map[string]any
if err := json.Unmarshal([]byte(content), &config); err != nil {
t.Fatalf("parse %s: %v", vercelconfig.FileName, err)
}
if !vercelconfig.DeploymentDisabled(config) {
t.Fatalf("expected %s to disable %s, got %s", vercelconfig.FileName, vercelconfig.BranchPattern, content)
}
}

func TestWriteCommitted_MergesVercelConfigOnMetadataBranch(t *testing.T) {
repo, _ := setupBranchTestRepo(t)
worktree, err := repo.Worktree()
if err != nil {
t.Fatalf("repo.Worktree() error = %v", err)
}
repoRoot := worktree.Filesystem.Root()

entireDir := filepath.Join(repoRoot, ".entire")
if err := os.MkdirAll(entireDir, 0o755); err != nil {
t.Fatalf("mkdir .entire: %v", err)
}
if err := os.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled":true,"vercel":true}`), 0o644); err != nil {
t.Fatalf("write settings.json: %v", err)
}

initialConfig := []byte(`{
"cleanUrls": true,
"git": {
"deploymentEnabled": {
"main": true
}
}
}
`)
blobHash, err := CreateBlobFromContent(repo, initialConfig)
if err != nil {
t.Fatalf("CreateBlobFromContent() error = %v", err)
}
treeHash, err := BuildTreeFromEntries(context.Background(), repo, map[string]object.TreeEntry{
vercelconfig.FileName: {Name: vercelconfig.FileName, Mode: filemode.Regular, Hash: blobHash},
})
if err != nil {
t.Fatalf("BuildTreeFromEntries() error = %v", err)
}

store := NewGitStore(repo)
commitHash, err := store.createCommit(treeHash, plumbing.ZeroHash, "Initialize metadata branch", "Test", "[email protected]")
if err != nil {
t.Fatalf("createCommit() error = %v", err)
}
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), commitHash)); err != nil {
t.Fatalf("set metadata branch ref: %v", err)
}

cpID := id.MustCheckpointID("abcdef123456")
err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: cpID,
SessionID: "test-session-id",
Strategy: "manual-commit",
Transcript: redact.AlreadyRedacted([]byte(`{"test": true}`)),
AuthorName: "Test",
AuthorEmail: "[email protected]",
})
if err != nil {
t.Fatalf("WriteCommitted() error = %v", err)
}

ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
if err != nil {
t.Fatalf("metadata branch ref: %v", err)
}
commit, err := repo.CommitObject(ref.Hash())
if err != nil {
t.Fatalf("metadata commit: %v", err)
}
tree, err := commit.Tree()
if err != nil {
t.Fatalf("metadata tree: %v", err)
}
file, err := tree.File(vercelconfig.FileName)
if err != nil {
t.Fatalf("expected %s on metadata branch: %v", vercelconfig.FileName, err)
}
content, err := file.Contents()
if err != nil {
t.Fatalf("read %s: %v", vercelconfig.FileName, err)
}

var config map[string]any
if err := json.Unmarshal([]byte(content), &config); err != nil {
t.Fatalf("parse %s: %v", vercelconfig.FileName, err)
}
if config["cleanUrls"] != true {
t.Fatalf("expected cleanUrls to be preserved, got %#v", config["cleanUrls"])
}
gitConfig, ok := config["git"].(map[string]any)
if !ok {
t.Fatalf("expected git object, got %#v", config["git"])
}
deploymentEnabled, ok := gitConfig["deploymentEnabled"].(map[string]any)
if !ok {
t.Fatalf("expected deploymentEnabled object, got %#v", gitConfig["deploymentEnabled"])
}
if deploymentEnabled["main"] != true {
t.Fatalf("expected main rule to be preserved, got %#v", deploymentEnabled["main"])
}
if deploymentEnabled[vercelconfig.BranchPattern] != false {
t.Fatalf("expected %s to be disabled, got %#v", vercelconfig.BranchPattern, deploymentEnabled[vercelconfig.BranchPattern])
}
}

// verifyBranchInMetadata reads and verifies the branch field in metadata.json.
func verifyBranchInMetadata(t *testing.T, repo *git.Repository, checkpointID id.CheckpointID, expectedBranch string, shouldOmit bool) {
t.Helper()
Expand Down
77 changes: 77 additions & 0 deletions cmd/entire/cli/checkpoint/committed.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import (
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/settings"
"github.com/entireio/cli/cmd/entire/cli/trailers"
"github.com/entireio/cli/cmd/entire/cli/validation"
"github.com/entireio/cli/cmd/entire/cli/vercelconfig"
"github.com/entireio/cli/cmd/entire/cli/versioninfo"
"github.com/entireio/cli/perf"
"github.com/entireio/cli/redact"
Expand Down Expand Up @@ -102,6 +104,10 @@ func (s *GitStore) WriteCommitted(ctx context.Context, opts WriteCommittedOption
if err != nil {
return err
}
newTreeHash, err = s.maybeMergeVercelConfig(ctx, newTreeHash)
if err != nil {
return err
}

commitMsg := s.buildCommitMessage(opts, taskMetadataPath)
newCommitHash, err := s.createCommit(newTreeHash, parentHash, commitMsg, opts.AuthorName, opts.AuthorEmail)
Expand Down Expand Up @@ -1334,6 +1340,10 @@ func (s *GitStore) UpdateCommitted(ctx context.Context, opts UpdateCommittedOpti
if err != nil {
return err
}
newTreeHash, err = s.maybeMergeVercelConfig(ctx, newTreeHash)
if err != nil {
return err
}

authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
commitMsg := fmt.Sprintf("Finalize transcript for Checkpoint: %s", opts.CheckpointID)
Expand Down Expand Up @@ -1411,6 +1421,10 @@ func (s *GitStore) ensureSessionsBranch(ctx context.Context) error {
if err != nil {
return err
}
emptyTreeHash, err = s.maybeMergeVercelConfig(ctx, emptyTreeHash)
if err != nil {
return err
}

authorName, authorEmail := GetGitAuthorFromRepo(s.repo)
commitHash, err := s.createCommit(emptyTreeHash, plumbing.ZeroHash, "Initialize sessions branch", authorName, authorEmail)
Expand All @@ -1425,6 +1439,69 @@ func (s *GitStore) ensureSessionsBranch(ctx context.Context) error {
return nil
}

func (s *GitStore) maybeMergeVercelConfig(_ context.Context, rootTreeHash plumbing.Hash) (plumbing.Hash, error) {
repoRoot, rootErr := repositoryRoot(s.repo)
if rootErr == nil {
projectSettings, settingsErr := settings.LoadFromRepoRoot(repoRoot)
if settingsErr == nil && projectSettings.Vercel {
config := make(map[string]any)
var existingContents string
if rootTreeHash != plumbing.ZeroHash {
tree, treeErr := s.repo.TreeObject(rootTreeHash)
if treeErr != nil && !errors.Is(treeErr, plumbing.ErrObjectNotFound) {
return plumbing.ZeroHash, fmt.Errorf("read metadata tree: %w", treeErr)
}
if treeErr == nil {
file, fileErr := tree.File(vercelconfig.FileName)
if fileErr == nil {
contents, contentsErr := file.Contents()
if contentsErr != nil {
return plumbing.ZeroHash, fmt.Errorf("read %s from metadata branch: %w", vercelconfig.FileName, contentsErr)
}
existingContents = contents
if unmarshalErr := json.Unmarshal([]byte(contents), &config); unmarshalErr != nil {
config = make(map[string]any)
}
}
}
}

vercelconfig.MergeDeploymentDisabled(config)
output, err := vercelconfig.Marshal(config)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("marshal %s: %w", vercelconfig.FileName, err)
}
if string(output) == existingContents {
return rootTreeHash, nil
}

blobHash, err := CreateBlobFromContent(s.repo, output)
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("create %s blob: %w", vercelconfig.FileName, err)
}

newTreeHash, err := UpdateSubtree(s.repo, rootTreeHash, nil, []object.TreeEntry{
{Name: vercelconfig.FileName, Mode: filemode.Regular, Hash: blobHash},
}, UpdateSubtreeOptions{MergeMode: MergeKeepExisting})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("update metadata subtree with %s: %w", vercelconfig.FileName, err)
}

return newTreeHash, nil
}
}

return rootTreeHash, nil
}

func repositoryRoot(repo *git.Repository) (string, error) {
worktree, err := repo.Worktree()
if err != nil {
return "", fmt.Errorf("open worktree: %w", err)
}
return worktree.Filesystem.Root(), nil
}

// getFetchingTree returns a FetchingTree for the metadata branch.
// If a blob fetcher is configured on the store, File() calls on the returned
// tree will automatically fetch missing blobs from the remote.
Expand Down
25 changes: 25 additions & 0 deletions cmd/entire/cli/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ type EntireSettings struct {
// plugins (entire-agent-* binaries on $PATH). Defaults to false.
ExternalAgents bool `json:"external_agents,omitempty"`

// Vercel indicates that the repository uses Vercel and the metadata branch
// should include a vercel.json that disables deployments for Entire branches.
Vercel bool `json:"vercel,omitempty"`

// Deprecated: no longer used. Exists to tolerate old settings files
// that still contain "strategy": "auto-commit" or similar.
Strategy string `json:"strategy,omitempty"`
Expand Down Expand Up @@ -116,6 +120,18 @@ func Load(ctx context.Context) (*EntireSettings, error) {
localSettingsFileAbs = EntireSettingsLocalFile // Fallback to relative
}

return loadMergedSettings(settingsFileAbs, localSettingsFileAbs)
}

// LoadFromRepoRoot loads settings relative to a repository root path.
func LoadFromRepoRoot(repoRoot string) (*EntireSettings, error) {
return loadMergedSettings(
filepath.Join(repoRoot, EntireSettingsFile),
filepath.Join(repoRoot, EntireSettingsLocalFile),
)
}

func loadMergedSettings(settingsFileAbs, localSettingsFileAbs string) (*EntireSettings, error) {
// Load base settings
settings, err := loadFromFile(settingsFileAbs)
if err != nil {
Expand Down Expand Up @@ -300,6 +316,15 @@ func mergeJSON(settings *EntireSettings, data []byte) error {
settings.ExternalAgents = ea
}

// Override vercel if present
if vercelRaw, ok := raw["vercel"]; ok {
var vercel bool
if err := json.Unmarshal(vercelRaw, &vercel); err != nil {
return fmt.Errorf("parsing vercel field: %w", err)
}
settings.Vercel = vercel
}

return nil
}

Expand Down
32 changes: 31 additions & 1 deletion cmd/entire/cli/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ func TestLoad_AcceptsValidKeys(t *testing.T) {
"strategy_options": {"key": "value"},
"telemetry": true,
"redaction": {"pii": {"enabled": true, "email": true, "phone": false}},
"external_agents": true
"external_agents": true,
"vercel": true
}`
if err := os.WriteFile(settingsFile, []byte(settingsContent), 0644); err != nil {
t.Fatalf("failed to write settings file: %v", err)
Expand Down Expand Up @@ -106,6 +107,9 @@ func TestLoad_AcceptsValidKeys(t *testing.T) {
if settings.Redaction.PII.Phone == nil || *settings.Redaction.PII.Phone {
t.Error("expected redaction.pii.phone to be false")
}
if !settings.Vercel {
t.Error("expected vercel to be true")
}
}

func TestLoad_LocalSettingsRejectsUnknownKeys(t *testing.T) {
Expand Down Expand Up @@ -412,6 +416,32 @@ func TestLoad_ExternalAgentsField(t *testing.T) {
}
}

func TestLoadFromRepoRoot_MergesLocalOverrides(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.WriteFile(filepath.Join(entireDir, "settings.json"), []byte(`{"enabled": true, "vercel": true}`), 0o644); err != nil {
t.Fatalf("failed to write settings.json: %v", err)
}
if err := os.WriteFile(filepath.Join(entireDir, "settings.local.json"), []byte(`{"log_level": "debug"}`), 0o644); err != nil {
t.Fatalf("failed to write settings.local.json: %v", err)
}

s, err := LoadFromRepoRoot(tmpDir)
if err != nil {
t.Fatalf("LoadFromRepoRoot() error = %v", err)
}
if !s.Vercel {
t.Error("expected vercel to be true")
}
if s.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want %q", s.LogLevel, "debug")
}
}

func TestMergeJSON_ExternalAgents(t *testing.T) {
tmpDir := t.TempDir()

Expand Down
Loading
Loading