Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 5 additions & 3 deletions cmd/entire/cli/agent/opencode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ func (a *OpenCodeAgent) PrepareTranscript(ctx context.Context, sessionRef string
}

// sessionTranscriptPath validates the session ID and returns the expected transcript path.
// Matches GetSessionDir(repoRoot)/ResolveSessionFile(...) so the live export and the
// path that AgentForTranscriptPath / rewind agree on are exactly the same location.
func sessionTranscriptPath(ctx context.Context, sessionID string) (string, error) {
if err := validation.ValidateSessionID(sessionID); err != nil {
Comment thread
Soph marked this conversation as resolved.
Outdated
return "", fmt.Errorf("invalid session ID for transcript path: %w", err)
Expand All @@ -154,15 +156,15 @@ func sessionTranscriptPath(ctx context.Context, sessionID string) (string, error
if err != nil {
repoRoot = "."
}
return filepath.Join(repoRoot, paths.EntireTmpDir, sessionID+".json"), nil
return filepath.Join(repoRoot, paths.EntireTmpDir, OpenCodeSessionSubdir, sessionID+".json"), nil
}

// fetchAndCacheExport calls `opencode export <sessionID>` and writes the result
// to a temporary file. Returns the path to the temp file.
//
// Integration testing: Set ENTIRE_TEST_OPENCODE_MOCK_EXPORT=1 to skip the
// `opencode export` call and use pre-written mock data instead. Tests must
// pre-write the transcript file to .entire/tmp/<sessionID>.json before
// pre-write the transcript file to .entire/tmp/opencode/<sessionID>.json before
// triggering the hook. See integration_test/hooks.go:SimulateOpenCodeTurnEnd.
func (a *OpenCodeAgent) fetchAndCacheExport(ctx context.Context, sessionID string) (string, error) {
if err := validation.ValidateSessionID(sessionID); err != nil {
Expand All @@ -175,7 +177,7 @@ func (a *OpenCodeAgent) fetchAndCacheExport(ctx context.Context, sessionID strin
repoRoot = "."
}

tmpDir := filepath.Join(repoRoot, paths.EntireTmpDir)
tmpDir := filepath.Join(repoRoot, paths.EntireTmpDir, OpenCodeSessionSubdir)
tmpFile := filepath.Join(tmpDir, sessionID+".json")

// Integration test mode: use pre-written mock file without calling opencode export
Expand Down
56 changes: 56 additions & 0 deletions cmd/entire/cli/agent/opencode/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,62 @@ func TestParseHookEvent_TurnEnd_InvalidSessionID(t *testing.T) {
}
}

// Note: GetSessionDir tests cannot use t.Parallel() because t.Setenv()
// modifies process-global state.

func TestGetSessionDir_UnderRepoEntireTmpOpencode(t *testing.T) {
t.Setenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR", "")
ag := &OpenCodeAgent{}

repo := t.TempDir()
got, err := ag.GetSessionDir(repo)
require.NoError(t, err)
require.Equal(t, filepath.Join(repo, paths.EntireTmpDir, OpenCodeSessionSubdir), got)
}

func TestGetSessionDir_UniquePrefixFromBareEntireTmp(t *testing.T) {
t.Setenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR", "")
ag := &OpenCodeAgent{}

repo := t.TempDir()
dir, err := ag.GetSessionDir(repo)
require.NoError(t, err)

// A bare .entire/tmp/<id>.jsonl file (e.g. another agent's scratch file)
// must NOT prefix-match opencode's session dir; otherwise
// agent.AgentForTranscriptPath would misroute it.
bare := filepath.Join(repo, paths.EntireTmpDir, "some-other-agent.jsonl")
require.False(t, strings.HasPrefix(bare, dir+string(filepath.Separator)),
"bare .entire/tmp file %q must not appear under opencode session dir %q", bare, dir)
}

func TestGetSessionDir_TestOverrideTakesPrecedence(t *testing.T) {
override := t.TempDir()
t.Setenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR", override)

ag := &OpenCodeAgent{}
got, err := ag.GetSessionDir(t.TempDir())
require.NoError(t, err)
require.Equal(t, override, got)
}

func TestGetSessionDir_PerWorktreeIsolation(t *testing.T) {
t.Setenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR", "")
ag := &OpenCodeAgent{}

worktreeA := t.TempDir()
worktreeB := t.TempDir()

dirA, err := ag.GetSessionDir(worktreeA)
require.NoError(t, err)
dirB, err := ag.GetSessionDir(worktreeB)
require.NoError(t, err)

require.NotEqual(t, dirA, dirB, "different worktrees must map to different session dirs")
require.True(t, strings.HasPrefix(dirA, worktreeA), "session dir A must live inside worktree A")
require.True(t, strings.HasPrefix(dirB, worktreeB), "session dir B must live inside worktree B")
}

func TestFetchAndCacheExport_WritesAndValidatesExportFile(t *testing.T) {
tmpDir := t.TempDir()
t.Chdir(tmpDir)
Expand Down
29 changes: 15 additions & 14 deletions cmd/entire/cli/agent/opencode/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"log/slog"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/entireio/cli/cmd/entire/cli/agent"
Expand Down Expand Up @@ -165,19 +164,30 @@ func (a *OpenCodeAgent) GetSessionID(input *agent.HookInput) string {
return input.SessionID
}

// OpenCodeSessionSubdir is the per-agent subdirectory under .entire/tmp/
// that holds OpenCode session transcripts. The subdir prevents
// agent.AgentForTranscriptPath from misattributing other agents' files that
// happen to land in .entire/tmp/ (notably in integration tests) to OpenCode
// purely on a prefix match. lifecycle.go composes this with paths.EntireTmpDir
// when writing the live transcript so the two paths stay in lockstep.
const OpenCodeSessionSubdir = "opencode"

// GetSessionDir returns the directory where Entire stores OpenCode session transcripts.
// Transcripts are ephemeral handoff files between the TS plugin and the Go hook handler.
// Once checkpointed, the data lives on git refs and the file is disposable.
// Stored in os.TempDir()/entire-opencode/<sanitized-path>/ to avoid squatting on
// OpenCode's own directories (~/.opencode/ is project-level, not home-level).
// Stored under <repoPath>/.entire/tmp/opencode/ to keep handoff files inside the
// repo (worktree-safe, gitignored via .entire/.gitignore) and out of a shared
// os.TempDir() location where other local users could read restored transcripts.
// The opencode/ subdir gives the directory a unique prefix so agent routing
// based on GetSessionDir can't confuse OpenCode with siblings that share
// .entire/tmp/ as a scratch space.
func (a *OpenCodeAgent) GetSessionDir(repoPath string) (string, error) {
// Check for test environment override
if override := os.Getenv("ENTIRE_TEST_OPENCODE_PROJECT_DIR"); override != "" {
return override, nil
}

projectDir := SanitizePathForOpenCode(repoPath)
return filepath.Join(os.TempDir(), "entire-opencode", projectDir), nil
return filepath.Join(repoPath, paths.EntireTmpDir, OpenCodeSessionSubdir), nil
Comment thread
Soph marked this conversation as resolved.
Outdated
}

func (a *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string {
Expand Down Expand Up @@ -268,12 +278,3 @@ func (a *OpenCodeAgent) FormatResumeCommand(sessionID string) string {
}
return "opencode -s " + sessionID
}

// nonAlphanumericRegex matches any non-alphanumeric character.
var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]`)

// SanitizePathForOpenCode converts a path to a safe directory name.
// Replaces any non-alphanumeric character with a dash (same approach as Claude/Gemini).
func SanitizePathForOpenCode(path string) string {
return nonAlphanumericRegex.ReplaceAllString(path, "-")
}
33 changes: 33 additions & 0 deletions cmd/entire/cli/checkpoint/remote/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ func Fetch(ctx context.Context, opts FetchOptions) ([]byte, error) {
return out, nil
}

// fetchBlobsBatchSize caps how many hashes are passed to a single
// `git fetch-pack` invocation. Each hash adds 41 bytes (40 hex chars + a
// separator) to the argv; with a typical Linux ARG_MAX of ~2 MiB we'd run
// out of room around 50 k hashes. 500 keeps us comfortably below that
// while still amortizing the per-call git startup cost.
const fetchBlobsBatchSize = 500

// FetchBlobs fetches specific objects (typically blobs) by hash from a remote.
// Uses `git fetch-pack` rather than `git fetch` because the high-level
// porcelain enforces partial-clone integrity checks that reject blob-only
Expand All @@ -80,9 +87,35 @@ func Fetch(ctx context.Context, opts FetchOptions) ([]byte, error) {
// and exits — which is exactly what we want when grabbing individual blobs
// by SHA. Works against GitHub for any reachable object, including blobs.
//
// Hashes are submitted in batches (see fetchBlobsBatchSize) so that very
// large fetches don't exceed the OS argv length limit. A failure on any
// batch aborts the fetch and returns the error; callers handle fallback
// (e.g. a full metadata branch fetch) at a higher layer.
//
// The remote should be a URL (not a remote name) to avoid persisting promisor
// settings onto the named remote. Use FetchURL to obtain the URL.
func FetchBlobs(ctx context.Context, remote string, hashes []string) error {
if len(hashes) == 0 {
return nil
}

for i := 0; i < len(hashes); i += fetchBlobsBatchSize {
end := i + fetchBlobsBatchSize
if end > len(hashes) {
end = len(hashes)
}
if err := fetchBlobsBatchFn(ctx, remote, hashes[i:end]); err != nil {
return err
}
}
return nil
}

// fetchBlobsBatchFn is swappable for tests so the batching loop can be
// exercised without running real git fetch-pack commands.
var fetchBlobsBatchFn = fetchBlobsBatch //nolint:gochecknoglobals // intentional test seam

func fetchBlobsBatch(ctx context.Context, remote string, hashes []string) error {
args := []string{"fetch-pack", remote}
args = append(args, hashes...)

Expand Down
73 changes: 73 additions & 0 deletions cmd/entire/cli/checkpoint/remote/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,79 @@ func TestIsLocalPath(t *testing.T) {
}
}

func TestFetchBlobs_EmptyHashesNoOp(t *testing.T) {
t.Parallel()

called := false
restore := withFetchBlobsBatchStub(t, func(_ context.Context, _ string, _ []string) error {
called = true
return nil
})
defer restore()

require.NoError(t, FetchBlobs(context.Background(), "https://example.com/repo.git", nil))
assert.False(t, called, "FetchBlobs with no hashes must not invoke git")
}

func TestFetchBlobs_BatchesLargeHashLists(t *testing.T) {
// Note: cannot t.Parallel() — overrides a package-level var.
const total = fetchBlobsBatchSize*2 + 3 // 1003 → expect 3 batches: 500, 500, 3

hashes := make([]string, total)
for i := range hashes {
hashes[i] = fmt.Sprintf("%040x", i)
}

var batches [][]string
restore := withFetchBlobsBatchStub(t, func(_ context.Context, _ string, h []string) error {
// Copy: the slice header points at a sub-slice of the caller's hashes.
got := make([]string, len(h))
copy(got, h)
batches = append(batches, got)
return nil
})
defer restore()

require.NoError(t, FetchBlobs(context.Background(), "https://example.com/repo.git", hashes))

require.Len(t, batches, 3, "1003 hashes at batchSize=500 must produce 3 batches")
assert.Len(t, batches[0], fetchBlobsBatchSize)
assert.Len(t, batches[1], fetchBlobsBatchSize)
assert.Len(t, batches[2], 3)
assert.Equal(t, hashes[0], batches[0][0])
assert.Equal(t, hashes[fetchBlobsBatchSize], batches[1][0])
assert.Equal(t, hashes[total-1], batches[2][len(batches[2])-1])
}

func TestFetchBlobs_AbortsOnBatchFailure(t *testing.T) {
// Note: cannot t.Parallel() — overrides a package-level var.
hashes := make([]string, fetchBlobsBatchSize+10)
for i := range hashes {
hashes[i] = fmt.Sprintf("%040x", i)
}

calls := 0
wantErr := errors.New("fetch-pack failed")
restore := withFetchBlobsBatchStub(t, func(_ context.Context, _ string, _ []string) error {
calls++
return wantErr
})
defer restore()

err := FetchBlobs(context.Background(), "https://example.com/repo.git", hashes)
require.ErrorIs(t, err, wantErr)
assert.Equal(t, 1, calls, "FetchBlobs must abort after the first failing batch")
}

func withFetchBlobsBatchStub(t *testing.T, stub func(context.Context, string, []string) error) func() {
t.Helper()
original := fetchBlobsBatchFn
fetchBlobsBatchFn = stub
return func() {
fetchBlobsBatchFn = original
}
}

// envToMap converts an env slice to a map for easy assertions.
// For duplicate keys, the last value wins.
func envToMap(env []string) map[string]string {
Expand Down
16 changes: 9 additions & 7 deletions cmd/entire/cli/integration_test/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"

"github.com/entireio/cli/cmd/entire/cli/agent/opencode"
"github.com/entireio/cli/cmd/entire/cli/strategy"
"github.com/entireio/cli/cmd/entire/cli/testutil"
)
Expand Down Expand Up @@ -1382,13 +1383,13 @@ func (r *OpenCodeHookRunner) SimulateOpenCodeTurnEnd(sessionID, transcriptPath s
r.T.Helper()

// For integration tests, write the mock transcript to the location where the
// lifecycle handler expects it (.entire/tmp/<session_id>.json)
// lifecycle handler expects it (.entire/tmp/opencode/<session_id>.json)
if transcriptPath != "" {
srcData, err := os.ReadFile(transcriptPath)
if err != nil {
r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to read transcript from %q: %v", transcriptPath, err)
}
destDir := filepath.Join(r.RepoDir, ".entire", "tmp")
destDir := filepath.Join(r.RepoDir, ".entire", "tmp", opencode.OpenCodeSessionSubdir)
if err := os.MkdirAll(destDir, 0o755); err != nil {
r.T.Fatalf("SimulateOpenCodeTurnEnd: failed to create directory %q: %v", destDir, err)
}
Expand Down Expand Up @@ -1556,18 +1557,19 @@ func (env *TestEnv) SimulateOpenCodeSessionEnd(sessionID, transcriptPath string)
return runner.SimulateOpenCodeSessionEnd(sessionID, transcriptPath)
}

// CopyTranscriptToEntireTmp copies an OpenCode transcript to .entire/tmp/<sessionID>.json.
// This simulates what `opencode export` does in production. Required for mid-turn commits
// where PrepareTranscript calls fetchAndCacheExport, which in mock mode expects the file
// to already exist at .entire/tmp/<sessionID>.json.
// CopyTranscriptToEntireTmp copies an OpenCode transcript to
// .entire/tmp/opencode/<sessionID>.json. This simulates what `opencode export`
// does in production. Required for mid-turn commits where PrepareTranscript
// calls fetchAndCacheExport, which in mock mode expects the file to already
// exist at that path.
func (env *TestEnv) CopyTranscriptToEntireTmp(sessionID, transcriptPath string) {
env.T.Helper()

srcData, err := os.ReadFile(transcriptPath)
if err != nil {
env.T.Fatalf("CopyTranscriptToEntireTmp: failed to read transcript from %q: %v", transcriptPath, err)
}
destDir := filepath.Join(env.RepoDir, ".entire", "tmp")
destDir := filepath.Join(env.RepoDir, ".entire", "tmp", opencode.OpenCodeSessionSubdir)
if err := os.MkdirAll(destDir, 0o755); err != nil {
env.T.Fatalf("CopyTranscriptToEntireTmp: failed to create directory %q: %v", destDir, err)
}
Expand Down
21 changes: 21 additions & 0 deletions cmd/entire/cli/osroot/osroot.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,27 @@ func WriteFile(root *os.Root, name string, data []byte, perm os.FileMode) (retEr
return nil
}

// WriteFileFromReader streams data from src to the named file relative to
// root using os.Root for traversal-resistant access. Creates the file if it
// doesn't exist, truncates it if it does. Unlike WriteFile, the source is
// not buffered in memory — use this for files that could be large.
func WriteFileFromReader(root *os.Root, name string, src io.Reader, perm os.FileMode) (retErr error) {
f, err := root.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err //nolint:wrapcheck // preserve original error for os.IsNotExist checks
}
defer func() {
if closeErr := f.Close(); closeErr != nil && retErr == nil {
retErr = closeErr
}
}()

if _, err := io.Copy(f, src); err != nil {
return err //nolint:wrapcheck // preserve original error
}
return nil
}

// Remove removes the named file relative to root using os.Root for
// traversal-resistant access. Returns nil if the file doesn't exist.
func Remove(root *os.Root, name string) error {
Expand Down
Loading
Loading