multica/server/internal/daemon/repocache/cache_test.go
Jiayuan cdc1ac708e feat(daemon): agent-driven repo checkout with bare clone cache
Agents now decide which repo to use based on issue context and check out
repos on demand via `multica repo checkout <url>`. Workspace repos are
cached locally as bare clones for fast worktree creation.

Key changes:
- Add repocache package for bare clone management (clone, fetch, worktree)
- Add `multica repo checkout` CLI command that talks to local daemon
- Add POST /repo/checkout endpoint on daemon health server
- Pass workspace repos metadata through register + task claim responses
- Remove pre-created worktrees from execenv (workdir starts empty)
- Update CLAUDE.md template to instruct agents to use `multica repo checkout`
- Pass MULTICA_DAEMON_PORT, WORKSPACE_ID, AGENT_NAME, TASK_ID env vars to agent
2026-03-29 19:37:48 +08:00

262 lines
7.2 KiB
Go

package repocache
import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func testLogger() *slog.Logger {
return slog.Default()
}
func TestBareDirName(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"https://github.com/org/my-repo.git", "my-repo.git"},
{"https://github.com/org/my-repo", "my-repo.git"},
{"git@github.com:org/my-repo.git", "my-repo.git"},
{"git@github.com:org/my-repo", "my-repo.git"},
{"https://github.com/org/repo/", "repo.git"},
{"my-repo", "my-repo.git"},
{"", "repo.git"},
}
for _, tt := range tests {
if got := bareDirName(tt.input); got != tt.want {
t.Errorf("bareDirName(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestIsBareRepo(t *testing.T) {
t.Parallel()
// A directory with a HEAD file should be detected as bare.
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644)
if !isBareRepo(dir) {
t.Error("expected bare repo to be detected")
}
// An empty directory should not.
emptyDir := t.TempDir()
if isBareRepo(emptyDir) {
t.Error("expected empty dir to not be detected as bare repo")
}
}
// createTestRepo creates a local git repo with an initial commit and returns its path.
func createTestRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, args := range [][]string{
{"init", dir},
{"-C", dir, "commit", "--allow-empty", "-m", "initial"},
} {
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)
}
}
return dir
}
func TestSyncAndLookup(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
// Sync should clone the repo.
err := cache.Sync("ws-123", []RepoInfo{
{URL: sourceRepo, Description: "test repo"},
})
if err != nil {
t.Fatalf("Sync failed: %v", err)
}
// Lookup should find the cached repo.
path := cache.Lookup("ws-123", sourceRepo)
if path == "" {
t.Fatal("expected to find cached repo")
}
if !isBareRepo(path) {
t.Fatalf("expected bare repo at %s", path)
}
// Lookup for unknown URL should return empty.
if got := cache.Lookup("ws-123", "https://github.com/org/unknown"); got != "" {
t.Fatalf("expected empty for unknown URL, got %q", got)
}
// Lookup for unknown workspace should return empty.
if got := cache.Lookup("ws-999", sourceRepo); got != "" {
t.Fatalf("expected empty for unknown workspace, got %q", got)
}
}
func TestSyncFetchesExisting(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
// First sync: clone.
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("first sync failed: %v", err)
}
// Record the HEAD commit hash in the cache.
barePath := cache.Lookup("ws-1", sourceRepo)
oldHead := gitHead(t, barePath)
// Add a commit to source.
cmd := exec.Command("git", "-C", sourceRepo, "commit", "--allow-empty", "-m", "second")
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.Fatalf("add commit failed: %s: %v", out, err)
}
sourceHead := gitHead(t, sourceRepo)
if sourceHead == oldHead {
t.Fatal("source HEAD should differ after new commit")
}
// Second sync: should fetch (not re-clone).
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("second sync failed: %v", err)
}
// Verify the cache HEAD was updated.
newHead := gitHead(t, barePath)
if newHead == oldHead {
t.Fatal("expected cache HEAD to be updated after fetch")
}
if newHead != sourceHead {
t.Fatalf("expected cache HEAD %s to match source HEAD %s", newHead, sourceHead)
}
}
func gitHead(t *testing.T, repoPath string) string {
t.Helper()
cmd := exec.Command("git", "-C", repoPath, "rev-parse", "HEAD")
out, err := cmd.Output()
if err != nil {
t.Fatalf("git rev-parse HEAD failed in %s: %v", repoPath, err)
}
return strings.TrimSpace(string(out))
}
func TestWorktreeFromCache(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
barePath := cache.Lookup("ws-1", sourceRepo)
if barePath == "" {
t.Fatal("expected cached repo")
}
// Create a worktree from the bare cache — this is the actual use case.
worktreeDir := filepath.Join(t.TempDir(), "work")
cmd := exec.Command("git", "-C", barePath, "worktree", "add", "-b", "test-branch", worktreeDir, "HEAD")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("worktree add failed: %s: %v", out, err)
}
defer exec.Command("git", "-C", barePath, "worktree", "remove", "--force", worktreeDir).Run()
// Verify worktree exists and is on the right branch.
cmd = exec.Command("git", "-C", worktreeDir, "branch", "--show-current")
out, err := cmd.Output()
if err != nil {
t.Fatalf("show branch failed: %v", err)
}
if got := trimLine(string(out)); got != "test-branch" {
t.Fatalf("expected branch 'test-branch', got %q", got)
}
}
func TestCreateWorktree(t *testing.T) {
t.Parallel()
sourceRepo := createTestRepo(t)
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
t.Fatalf("sync failed: %v", err)
}
workDir := t.TempDir()
result, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: sourceRepo,
WorkDir: workDir,
AgentName: "Code Reviewer",
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
})
if err != nil {
t.Fatalf("CreateWorktree failed: %v", err)
}
// Verify the worktree was created.
if _, err := os.Stat(result.Path); os.IsNotExist(err) {
t.Fatalf("worktree path does not exist: %s", result.Path)
}
// Verify branch name format.
if !strings.HasPrefix(result.BranchName, "agent/code-reviewer/") {
t.Errorf("expected branch to start with 'agent/code-reviewer/', got %q", result.BranchName)
}
// Verify the worktree is on the correct branch.
cmd := exec.Command("git", "-C", result.Path, "branch", "--show-current")
out, err := cmd.Output()
if err != nil {
t.Fatalf("show branch failed: %v", err)
}
if got := strings.TrimSpace(string(out)); got != result.BranchName {
t.Errorf("expected branch %q, got %q", result.BranchName, got)
}
}
func TestCreateWorktreeNotCached(t *testing.T) {
t.Parallel()
cacheRoot := t.TempDir()
cache := New(cacheRoot, testLogger())
_, err := cache.CreateWorktree(WorktreeParams{
WorkspaceID: "ws-1",
RepoURL: "https://github.com/org/nonexistent",
WorkDir: t.TempDir(),
AgentName: "Agent",
TaskID: "test-task-id",
})
if err == nil {
t.Fatal("expected error for uncached repo")
}
if !strings.Contains(err.Error(), "not found in cache") {
t.Errorf("expected 'not found in cache' error, got: %v", err)
}
}
func trimLine(s string) string {
return strings.TrimSpace(s)
}