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
262 lines
7.2 KiB
Go
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)
|
|
}
|