Skip to content
Closed
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
44 changes: 36 additions & 8 deletions cmd/entire/cli/checkpoint/remote/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,22 @@ var sshTokenWarningOnce sync.Once //nolint:gochecknoglobals // intentional per-p

// FetchOptions configures a git fetch operation.
type FetchOptions struct {
Remote string // remote name or URL (required)
RefSpecs []string // one or more refspecs / object hashes
Shallow bool // adds --depth=1
NoTags bool // adds --no-tags
NoFilter bool // when true, skips --filter=blob:none even if filtered fetches are enabled
Remote string // remote name or URL (required)
RefSpecs []string // one or more refspecs / object hashes
NoTags bool // adds --no-tags
NoFilter bool // when true, skips --filter=blob:none even if filtered fetches are enabled
// Shallow adds --depth=1 to fetch only the tip commit and its tree. Use
// for tip-only probes (e.g. resolving the latest checkpoint metadata)
// where ancestry isn't needed. Creates .git/shallow state — callers that
// later require full history should opt into Unshallow on a follow-up
// fetch.
Shallow bool
// Unshallow adds --unshallow when the repository is currently shallow,
// triggering git to download the rest of the history for the fetched ref.
// Set this on metadata-repair / reconcile paths that need complete
// checkpoint ancestry. Do not set on generic branch fetches — it would
// silently convert a deliberately-shallow user clone into a full one.
Unshallow bool
Dir string // working directory (empty = CWD)
ExtraArgs []string // additional flags before remote (e.g., "--no-write-fetch-head")
}
Expand All @@ -46,14 +57,17 @@ type FetchOptions struct {
// resolve the name to a URL (to avoid persisting promisor settings) should call
// ResolveFetchTarget first and pass the resolved target as opts.Remote.
func Fetch(ctx context.Context, opts FetchOptions) ([]byte, error) {
args := []string{"fetch"}
args := []string{"fetch", "--no-auto-gc"}
if opts.NoTags {
args = append(args, "--no-tags")
}
if opts.Shallow {
args = append(args, opts.ExtraArgs...)
switch {
case opts.Shallow:
args = append(args, "--depth=1")
case opts.Unshallow && isShallowRepository(ctx, opts.Dir):
args = append(args, "--unshallow")
}
args = append(args, opts.ExtraArgs...)
if !opts.NoFilter && settings.IsFilteredFetchesEnabled(ctx) {
args = append(args, "--filter=blob:none")
}
Expand Down Expand Up @@ -309,6 +323,20 @@ func ResolveFetchTarget(ctx context.Context, target string) (string, error) {
return url, nil
}

// isShallowRepository returns true when the git repository at dir is shallow.
// An empty dir inherits the parent process's working directory, matching the
// semantics callers use when invoking Fetch with empty FetchOptions.Dir.
func isShallowRepository(ctx context.Context, dir string) bool {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-shallow-repository")
cmd.Dir = dir
disableTerminalPrompt(cmd)
out, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}

// newCommand creates an exec.Cmd for a git operation that may need
// checkpoint token authentication. If ENTIRE_CHECKPOINT_TOKEN is set:
// - if the target in args is (or resolves to) an SSH remote, the target is
Expand Down
113 changes: 110 additions & 3 deletions cmd/entire/cli/checkpoint/remote/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
Expand All @@ -27,10 +28,10 @@ func TestExtractRemoteFromArgs(t *testing.T) {
args []string
want string
}{
{"fetch with URL", []string{"fetch", "https://github.com/org/repo.git", "refs/heads/main"}, "https://github.com/org/repo.git"},
{"fetch with URL", []string{"fetch", "--no-auto-gc", "https://github.com/org/repo.git", "refs/heads/main"}, "https://github.com/org/repo.git"},
{"push with flags", []string{"push", "--no-verify", "--porcelain", "origin", "main"}, "origin"},
{"ls-remote", []string{"ls-remote", "origin", "refs/heads/*"}, "origin"},
{"fetch with filter", []string{"fetch", "--no-tags", "--filter=blob:none", "https://host/r.git", "+refs/heads/main:refs/tmp"}, "https://host/r.git"},
{"fetch with filter", []string{"fetch", "--no-auto-gc", "--no-tags", "--filter=blob:none", "https://host/r.git", "+refs/heads/main:refs/tmp"}, "https://host/r.git"},
{"empty args", []string{}, ""},
{"subcommand only", []string{"fetch"}, ""},
{"only flags", []string{"fetch", "--no-tags"}, ""},
Expand Down Expand Up @@ -256,6 +257,112 @@ func TestResolveFetchTarget(t *testing.T) {
})
}

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

t.Run("Unshallow=true deepens a shallow repo", func(t *testing.T) {
t.Parallel()
ctx := context.Background()

bareDir, cloneDir := setupShallowClone(ctx, t)
require.True(t, isShallowRepository(ctx, cloneDir), "test setup should produce a shallow repo")

out, err := Fetch(ctx, FetchOptions{
Remote: "file://" + bareDir,
RefSpecs: []string{"+refs/heads/main:refs/remotes/origin/main"},
NoTags: true,
Unshallow: true,
Dir: cloneDir,
})
require.NoError(t, err, "fetch output: %s", out)

assert.False(t, isShallowRepository(ctx, cloneDir),
"Unshallow=true should remove shallow state when the repo is shallow")
})

t.Run("Unshallow=false leaves shallow state alone", func(t *testing.T) {
t.Parallel()
ctx := context.Background()

bareDir, cloneDir := setupShallowClone(ctx, t)
require.True(t, isShallowRepository(ctx, cloneDir))

out, err := Fetch(ctx, FetchOptions{
Remote: "file://" + bareDir,
RefSpecs: []string{"+refs/heads/main:refs/remotes/origin/main"},
NoTags: true,
Dir: cloneDir,
})
require.NoError(t, err, "fetch output: %s", out)

assert.True(t, isShallowRepository(ctx, cloneDir),
"a fetch without Unshallow must not silently convert a shallow repo to a full one")
})
}

func TestFetch_Shallow(t *testing.T) {
t.Parallel()
ctx := context.Background()

Comment thread
pjbgf marked this conversation as resolved.
bareDir, _ := setupShallowClone(ctx, t)
// Make a fresh non-shallow clone, then fetch with Shallow=true and check
// .git/shallow appears.
cloneDir := t.TempDir()
runIsolatedGit(ctx, t, "", "clone", "--branch", "main", "file://"+bareDir, cloneDir)
require.False(t, isShallowRepository(ctx, cloneDir), "fresh clone should not be shallow")

out, err := Fetch(ctx, FetchOptions{
Remote: "file://" + bareDir,
RefSpecs: []string{"+refs/heads/main:refs/remotes/origin/main"},
NoTags: true,
Shallow: true,
Dir: cloneDir,
})
require.NoError(t, err, "fetch output: %s", out)

assert.True(t, isShallowRepository(ctx, cloneDir),
"Shallow=true should request --depth=1 and leave the repo shallow")
}

// setupShallowClone creates a bare origin, a seed repo with one commit pushed
// to it, a shallow (--depth=1) clone, and then advances origin by one more
// commit so that a subsequent fetch into the clone has work to do. Returns the
// bare origin path and the shallow clone path.
func setupShallowClone(ctx context.Context, t *testing.T) (bareDir, cloneDir string) {
t.Helper()
tmpDir := t.TempDir()
bareDir = filepath.Join(tmpDir, "bare.git")
seedDir := filepath.Join(tmpDir, "seed")
cloneDir = filepath.Join(tmpDir, "clone")

testutil.InitRepo(t, seedDir)
testutil.WriteFile(t, seedDir, "f.txt", "init")
testutil.GitAdd(t, seedDir, "f.txt")
testutil.GitCommit(t, seedDir, "init")

runIsolatedGit(ctx, t, "", "init", "--bare", bareDir)
runIsolatedGit(ctx, t, seedDir, "remote", "add", "origin", bareDir)
runIsolatedGit(ctx, t, seedDir, "push", "origin", "HEAD:refs/heads/main")
runIsolatedGit(ctx, t, "", "clone", "--depth=1", "--branch", "main", "file://"+bareDir, cloneDir)

testutil.WriteFile(t, seedDir, "f.txt", "init\nnext\n")
testutil.GitAdd(t, seedDir, "f.txt")
testutil.GitCommit(t, seedDir, "next")
runIsolatedGit(ctx, t, seedDir, "push", "origin", "HEAD:refs/heads/main")

return bareDir, cloneDir
}

func runIsolatedGit(ctx context.Context, t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
cmd.Env = testutil.GitIsolatedEnv()
require.NoError(t, cmd.Run(), "git %v", args)
}
Comment thread
pjbgf marked this conversation as resolved.

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

Expand Down Expand Up @@ -591,7 +698,7 @@ func TestNewCommand_GIT_TERMINAL_PROMPT_Coexistence(t *testing.T) {
t.Setenv(CheckpointTokenEnvVar, "coexist-token")

cmd := newCommand(context.Background(),
"fetch", "--no-tags", "--filter=blob:none", "https://github.com/org/repo.git", "refs/heads/main")
"fetch", "--no-auto-gc", "--no-tags", "--filter=blob:none", "https://github.com/org/repo.git", "refs/heads/main")
require.NotNil(t, cmd.Env)

cmd.Env = append(cmd.Env, "GIT_TERMINAL_PROMPT=0")
Expand Down
55 changes: 55 additions & 0 deletions cmd/entire/cli/fetch_no_config_pollution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@ import (
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
"github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/testutil"
"github.com/entireio/cli/redact"
"github.com/go-git/go-git/v6"
"github.com/stretchr/testify/require"
)

// TestFetchDoesNotPolluteOriginConfig is a regression test for #712.
Expand Down Expand Up @@ -94,6 +99,56 @@ func TestFetchDoesNotPolluteOriginConfig(t *testing.T) {
}
}

// TestFetchV2MainTreeOnly_DoesNotCreateShallowRepository guards the explain
// remote-fetch path for stale local v2 refs. V2 fetches promote through
// SafelyAdvanceLocalRef, which needs ancestry to prove a fast-forward. If this
// helper creates a shallow boundary, a remote descendant can look diverged and
// the local v2 ref stays stale.
func TestFetchV2MainTreeOnly_DoesNotCreateShallowRepository(t *testing.T) {
// Uses t.Chdir() — cannot run in parallel.

tmpDir := t.TempDir()
bareDir := filepath.Join(tmpDir, "bare.git")
producerDir := filepath.Join(tmpDir, "producer")
localDir := filepath.Join(tmpDir, "local")

runGit(t, tmpDir, "init", "--bare", bareDir)

testutil.InitRepo(t, producerDir)
testutil.WriteFile(t, producerDir, "README.md", "hello")
testutil.GitAdd(t, producerDir, "README.md")
testutil.GitCommit(t, producerDir, "init")
runGit(t, producerDir, "remote", "add", "origin", bareDir)

producerRepo, err := git.PlainOpen(producerDir)
if err != nil {
t.Fatalf("failed to open producer repo: %v", err)
}
writeV2CheckpointForExport(t, producerRepo, id.MustCheckpointID("121212121212"), checkpoint.WriteCommittedOptions{
SessionID: "fetch-v2-shallow-guard",
Transcript: redact.AlreadyRedacted([]byte(`{"type":"user","message":{"content":[{"type":"text","text":"hello"}]}}` + "\n")),
})

runGit(t, producerDir, "push", "origin", "HEAD:refs/heads/main", paths.V2MainRefName+":"+paths.V2MainRefName)
runGit(t, bareDir, "symbolic-ref", "HEAD", "refs/heads/main")
runGit(t, tmpDir, "clone", "--branch", "main", bareDir, localDir)

require.NoError(t, os.MkdirAll(filepath.Join(localDir, ".entire"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(localDir, ".entire", "settings.json"),
[]byte(`{"enabled": true, "strategy_options": {"filtered_fetches": true}}`),
0o644,
))

t.Chdir(localDir)

require.NoError(t, FetchV2MainTreeOnly(context.Background()))

if got := gitOutput(t, localDir, "rev-parse", "--is-shallow-repository"); got != "false" {
t.Fatalf("FetchV2MainTreeOnly left repository shallow = %s, want false", got)
}
}

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.CommandContext(t.Context(), "git", args...)
Expand Down
Loading