Files
multica/server/internal/daemon/repocache/partial_clone_test.go
Bohan Jiang 1157d3f139 fix(repocache): repair promisor config on isolated checkouts of a partial cache (#5772)
The isolated-checkout path used by Linux Codex builds a task-local
repository with `git clone --local` from the workspace's bare cache.
That command has two properties that combine badly when the cache is a
partial clone: it does not carry the promisor remote configuration
across, and it does not treat an incomplete source object store as an
error. The result is a checkout that exits 0 with every tracked file
reported as deleted, so an agent starts work in what looks like a
repository someone emptied.

Swap origin to the real remote and restore
`remote.origin.promisor` / `remote.origin.partialclonefilter` before the
first checkout, so git can lazily fetch the blobs it needs. Do the same
on the reuse path, where a workdir created against a complete cache can
later be resumed against a partial one.

No cache is created as a partial clone today, so this changes nothing
for existing installs; it is a prerequisite for the on-demand clone mode
in MUL-4983 and hardens a path that fails silently rather than loudly.

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-22 18:00:39 +08:00

212 lines
7.5 KiB
Go

package repocache
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// createFilterableTestRepo builds a source repository that can actually serve
// partial clones, and returns the URL to clone it from.
//
// Two details matter. First, `git clone --filter` is silently ignored for a
// plain filesystem path — git takes the local hardlink shortcut instead of
// speaking the pack protocol — so the URL has to be file://. Second, the
// server side must opt into filtering via uploadpack.allowFilter, exactly as
// GitHub and GitLab do.
//
// The commits carry real file contents, because a blobless clone only differs
// from a full one in whether those blobs were transferred.
func createFilterableTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com",
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Skipf("git setup failed: %s: %v", out, err)
}
}
run("init", dir)
run("-C", dir, "config", "uploadpack.allowFilter", "true")
for _, name := range partialCloneTestFiles {
if err := os.WriteFile(filepath.Join(dir, name), []byte("contents of "+name+"\n"), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
run("-C", dir, "add", name)
run("-C", dir, "commit", "-m", "add "+name)
}
return "file://" + dir
}
var partialCloneTestFiles = []string{"a.txt", "b.txt"}
// seedBloblessCache creates the workspace's bare cache for sourceRepo as a
// blobless partial clone, using the same two steps Cache.Sync performs for a
// cold miss. Returns the bare repo path.
func seedBloblessCache(t *testing.T, cache *Cache, workspaceID, sourceRepo string) string {
t.Helper()
barePath := filepath.Join(cache.root, workspaceID, bareDirName(sourceRepo))
if err := os.MkdirAll(filepath.Dir(barePath), 0o755); err != nil {
t.Fatalf("create workspace cache dir: %v", err)
}
if out, err := runGitCombinedOutput("clone", "--bare", "--filter=blob:none", sourceRepo, barePath); err != nil {
t.Fatalf("seed blobless cache: %s: %v", out, err)
}
if err := ensureRemoteTrackingLayout(barePath); err != nil {
t.Fatalf("ensure refspec: %v", err)
}
if !isPartialClone(barePath) {
t.Fatal("seeded cache is not a partial clone; the filter was ignored")
}
return barePath
}
// assertCheckoutIsComplete fails if the working tree is missing any tracked
// file. `git status --porcelain` reports missing blobs as deletions, which is
// how a silently-broken partial checkout shows up.
func assertCheckoutIsComplete(t *testing.T, checkoutPath string) {
t.Helper()
out, err := runGitOutput("-C", checkoutPath, "status", "--porcelain")
if err != nil {
t.Fatalf("git status: %v", err)
}
if status := strings.TrimSpace(string(out)); status != "" {
t.Fatalf("checkout is not clean, tracked files are missing:\n%s", status)
}
for _, name := range partialCloneTestFiles {
data, err := os.ReadFile(filepath.Join(checkoutPath, name))
if err != nil {
t.Fatalf("read %s from checkout: %v", name, err)
}
if got, want := string(data), "contents of "+name+"\n"; got != want {
t.Fatalf("%s = %q, want %q", name, got, want)
}
}
}
// The isolated-checkout path (Linux Codex) clones the cache with
// `git clone --local`. That command neither inherits a promisor remote's
// configuration nor refuses to run against an incomplete object store: it
// exits 0 and leaves every tracked file missing from the working tree. Without
// the promisor config restored on the new checkout, an agent starts work in
// what looks like a repository where someone deleted all the files.
func TestCreateIsolatedCheckoutFromPartialCacheHasFileContents(t *testing.T) {
t.Parallel()
sourceRepo := createFilterableTestRepo(t)
cache := New(t.TempDir(), testLogger())
seedBloblessCache(t, cache, "ws-1", sourceRepo)
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: t.TempDir(),
AgentName: "Linux Codex",
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
IsolatedGitMetadata: true,
})
if err != nil {
t.Fatalf("CreateWorktree failed: %v", err)
}
assertCheckoutIsComplete(t, result.Path)
// The checkout must keep resolving blobs on its own once the cache is out
// of the picture, so origin stays the real remote and carries the
// promisor marker.
origin, err := runGitOutput("-C", result.Path, "remote", "get-url", "origin")
if err != nil {
t.Fatalf("get origin URL: %v", err)
}
if got := strings.TrimSpace(string(origin)); got != sourceRepo {
t.Fatalf("origin URL = %q, want %q", got, sourceRepo)
}
if !isPartialClone(result.Path) {
t.Fatal("isolated checkout of a partial cache must be marked as a partial clone")
}
}
// A workdir created while the cache was still complete, then reused after the
// cache became partial, must be repaired on reuse rather than left
// half-populated.
func TestReusedIsolatedCheckoutRepairsPromisorConfig(t *testing.T) {
t.Parallel()
sourceRepo := createFilterableTestRepo(t)
cache := New(t.TempDir(), testLogger())
seedBloblessCache(t, cache, "ws-1", sourceRepo)
params := WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: t.TempDir(),
AgentName: "Linux Codex",
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
IsolatedGitMetadata: true,
}
first, err := cache.CreateWorktree(params)
if err != nil {
t.Fatalf("first CreateWorktree failed: %v", err)
}
if err := runGit("-C", first.Path, "config", "--unset", "remote.origin.promisor"); err != nil {
t.Fatalf("unset promisor: %v", err)
}
second, err := cache.CreateWorktree(params)
if err != nil {
t.Fatalf("second CreateWorktree failed: %v", err)
}
if !isPartialClone(second.Path) {
t.Fatal("reused isolated checkout must have its promisor config restored")
}
assertCheckoutIsComplete(t, second.Path)
}
// The linked-worktree path used by every other runtime shares the cache's own
// object store and config, so it lazily fetches without any extra wiring. This
// pins that difference so the promisor handling is not "fixed" by pushing it
// into the shared cache later.
func TestCreateWorktreeFromPartialCacheHasFileContents(t *testing.T) {
t.Parallel()
sourceRepo := createFilterableTestRepo(t)
cache := New(t.TempDir(), testLogger())
seedBloblessCache(t, cache, "ws-1", sourceRepo)
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: t.TempDir(),
AgentName: "tester",
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
})
if err != nil {
t.Fatalf("CreateWorktree failed: %v", err)
}
assertCheckoutIsComplete(t, result.Path)
}
func TestIsPartialClone(t *testing.T) {
t.Parallel()
sourceRepo := createFilterableTestRepo(t)
cache := New(t.TempDir(), testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
if isPartialClone(cache.Lookup("ws-1", sourceRepo)) {
t.Fatal("an ordinary full cache must not be reported as a partial clone")
}
blobless := seedBloblessCache(t, New(t.TempDir(), testLogger()), "ws-2", sourceRepo)
if !isPartialClone(blobless) {
t.Fatal("a blobless cache must be reported as a partial clone")
}
if isPartialClone(filepath.Join(t.TempDir(), "does-not-exist")) {
t.Fatal("a missing repository must not be reported as a partial clone")
}
}