Merge pull request #172 from multica-ai/forrestchang/agent-repo-context

feat(daemon): agent-driven repo checkout with bare clone cache
This commit is contained in:
Jiayuan Zhang 2026-03-29 19:44:27 +08:00 committed by GitHub
commit a10515fa74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1065 additions and 264 deletions

View file

@ -0,0 +1,92 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
)
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage repositories",
}
var repoCheckoutCmd = &cobra.Command{
Use: "checkout <url>",
Short: "Check out a repository into the working directory",
Long: "Creates a git worktree from the daemon's bare clone cache. Used by agents to check out repos on demand.",
Args: cobra.ExactArgs(1),
RunE: runRepoCheckout,
}
func init() {
repoCmd.AddCommand(repoCheckoutCmd)
}
func runRepoCheckout(cmd *cobra.Command, args []string) error {
repoURL := args[0]
daemonPort := os.Getenv("MULTICA_DAEMON_PORT")
if daemonPort == "" {
return fmt.Errorf("MULTICA_DAEMON_PORT not set (this command is intended to be run by an agent inside a daemon task)")
}
workspaceID := os.Getenv("MULTICA_WORKSPACE_ID")
agentName := os.Getenv("MULTICA_AGENT_NAME")
taskID := os.Getenv("MULTICA_TASK_ID")
// Use current working directory as the checkout target.
workDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
reqBody := map[string]string{
"url": repoURL,
"workspace_id": workspaceID,
"workdir": workDir,
"agent_name": agentName,
"task_id": taskID,
}
data, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Post(
fmt.Sprintf("http://127.0.0.1:%s/repo/checkout", daemonPort),
"application/json",
bytes.NewReader(data),
)
if err != nil {
return fmt.Errorf("connect to daemon: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("checkout failed: %s", string(body))
}
var result struct {
Path string `json:"path"`
BranchName string `json:"branch_name"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
fmt.Fprintf(os.Stdout, "%s\n", result.Path)
fmt.Fprintf(os.Stderr, "Checked out %s → %s (branch: %s)\n", repoURL, result.Path, result.BranchName)
return nil
}

View file

@ -31,6 +31,7 @@ func init() {
rootCmd.AddCommand(workspaceCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
}

View file

@ -46,6 +46,7 @@ func (c *CLIConfig) RemoveWatchedWorkspace(id string) bool {
return false
}
// CLIConfigPath returns the default path for the CLI config file.
func CLIConfigPath() (string, error) {
home, err := os.UserHomeDir()

View file

@ -152,14 +152,18 @@ func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error {
}, nil)
}
func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) {
var resp struct {
Runtimes []Runtime `json:"runtimes"`
}
// RegisterResponse holds the server's response to a daemon registration.
type RegisterResponse struct {
Runtimes []Runtime `json:"runtimes"`
Repos []RepoData `json:"repos"`
}
func (c *Client) Register(ctx context.Context, req map[string]any) (*RegisterResponse, error) {
var resp RegisterResponse
if err := c.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil {
return nil, err
}
return resp.Runtimes, nil
return &resp, nil
}
func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {

View file

@ -5,12 +5,14 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/multica-ai/multica/server/internal/cli"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
"github.com/multica-ai/multica/server/internal/daemon/repocache"
"github.com/multica-ai/multica/server/internal/daemon/usage"
"github.com/multica-ai/multica/server/pkg/agent"
)
@ -23,9 +25,10 @@ type workspaceState struct {
// Daemon is the local agent runtime that polls for and executes tasks.
type Daemon struct {
cfg Config
client *Client
logger *slog.Logger
cfg Config
client *Client
repoCache *repocache.Cache
logger *slog.Logger
mu sync.Mutex
workspaces map[string]*workspaceState
@ -35,9 +38,11 @@ type Daemon struct {
// New creates a new Daemon instance.
func New(cfg Config, logger *slog.Logger) *Daemon {
cacheRoot := filepath.Join(cfg.WorkspacesRoot, ".repos")
return &Daemon{
cfg: cfg,
client: NewClient(cfg.ServerBaseURL),
repoCache: repocache.New(cacheRoot, logger),
logger: logger,
workspaces: make(map[string]*workspaceState),
runtimeIndex: make(map[string]Runtime),
@ -130,23 +135,31 @@ func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
var registered int
for _, ws := range cfg.WatchedWorkspaces {
runtimes, err := d.registerRuntimesForWorkspace(ctx, ws.ID)
resp, err := d.registerRuntimesForWorkspace(ctx, ws.ID)
if err != nil {
d.logger.Error("failed to register runtimes", "workspace_id", ws.ID, "name", ws.Name, "error", err)
continue
}
runtimeIDs := make([]string, len(runtimes))
for i, rt := range runtimes {
runtimeIDs := make([]string, len(resp.Runtimes))
for i, rt := range resp.Runtimes {
runtimeIDs[i] = rt.ID
d.logger.Info("registered runtime", "workspace_id", ws.ID, "runtime_id", rt.ID, "provider", rt.Provider)
}
d.mu.Lock()
d.workspaces[ws.ID] = &workspaceState{workspaceID: ws.ID, runtimeIDs: runtimeIDs}
for _, rt := range runtimes {
for _, rt := range resp.Runtimes {
d.runtimeIndex[rt.ID] = rt
}
d.mu.Unlock()
d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(runtimes))
// Sync workspace repos to local cache.
if d.repoCache != nil && len(resp.Repos) > 0 {
if err := d.repoCache.Sync(ws.ID, repoDataToInfo(resp.Repos)); err != nil {
d.logger.Warn("repo cache sync failed", "workspace_id", ws.ID, "error", err)
}
}
d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(resp.Runtimes), "repos", len(resp.Repos))
registered++
}
@ -188,7 +201,7 @@ func (d *Daemon) providerToRuntimeMap() map[string]string {
return m
}
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) ([]Runtime, error) {
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) (*RegisterResponse, error) {
var runtimes []map[string]string
for name, entry := range d.cfg.Agents {
version, err := agent.DetectVersion(ctx, entry.Path)
@ -214,14 +227,14 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
"runtimes": runtimes,
}
rts, err := d.client.Register(ctx, req)
resp, err := d.client.Register(ctx, req)
if err != nil {
return nil, fmt.Errorf("register runtimes: %w", err)
}
if len(rts) == 0 {
if len(resp.Runtimes) == 0 {
return nil, fmt.Errorf("register runtimes: empty response")
}
return rts, nil
return resp, nil
}
// configWatchLoop periodically checks for config file changes and reloads workspaces.
@ -286,21 +299,29 @@ func (d *Daemon) reloadWorkspaces(ctx context.Context) {
// Register runtimes for newly added workspaces.
for id, name := range newIDs {
if !currentIDs[id] {
runtimes, err := d.registerRuntimesForWorkspace(ctx, id)
resp, err := d.registerRuntimesForWorkspace(ctx, id)
if err != nil {
d.logger.Error("register runtimes for new workspace failed", "workspace_id", id, "error", err)
continue
}
runtimeIDs := make([]string, len(runtimes))
for i, rt := range runtimes {
runtimeIDs := make([]string, len(resp.Runtimes))
for i, rt := range resp.Runtimes {
runtimeIDs[i] = rt.ID
}
d.mu.Lock()
d.workspaces[id] = &workspaceState{workspaceID: id, runtimeIDs: runtimeIDs}
for _, rt := range runtimes {
for _, rt := range resp.Runtimes {
d.runtimeIndex[rt.ID] = rt
}
d.mu.Unlock()
// Sync workspace repos to local cache.
if d.repoCache != nil && len(resp.Repos) > 0 {
if err := d.repoCache.Sync(id, repoDataToInfo(resp.Repos)); err != nil {
d.logger.Warn("repo cache sync failed", "workspace_id", id, "error", err)
}
}
d.logger.Info("now watching workspace", "workspace_id", id, "name", name)
}
}
@ -594,11 +615,14 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
}
// Prepare isolated execution environment.
// Repos are passed as metadata only — the agent checks them out on demand
// via `multica repo checkout <url>`.
taskCtx := execenv.TaskContextForEnv{
IssueID: task.IssueID,
AgentName: agentName,
AgentInstructions: instructions,
AgentSkills: convertSkillsForEnv(skills),
Repos: convertReposForEnv(task.Repos),
}
// Try to reuse the workdir from a previous task on the same (agent, issue) pair.
@ -630,11 +654,15 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
prompt := BuildPrompt(task)
// Pass the daemon's auth credentials so the spawned agent CLI can call
// the Multica API (e.g. `multica issue get`, `multica issue comment add`).
// Pass the daemon's auth credentials and context so the spawned agent CLI
// can call the Multica API and the local daemon (e.g. `multica repo checkout`).
agentEnv := map[string]string{
"MULTICA_TOKEN": d.client.Token(),
"MULTICA_SERVER_URL": d.cfg.ServerBaseURL,
"MULTICA_TOKEN": d.client.Token(),
"MULTICA_SERVER_URL": d.cfg.ServerBaseURL,
"MULTICA_DAEMON_PORT": fmt.Sprintf("%d", d.cfg.HealthPort),
"MULTICA_WORKSPACE_ID": d.workspaceIDForRuntime(task.RuntimeID),
"MULTICA_AGENT_NAME": agentName,
"MULTICA_TASK_ID": task.ID,
}
// Point Codex to the per-task CODEX_HOME so it discovers skills natively
// without polluting the system ~/.codex/skills/.
@ -650,7 +678,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
return TaskResult{}, fmt.Errorf("create agent backend: %w", err)
}
d.logger.Info("starting agent", "provider", provider, "task_id", task.ID, "workdir", env.WorkDir, "reused", task.PriorWorkDir != "" && env.WorkDir == task.PriorWorkDir, "branch", env.BranchName, "env_type", env.Type, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String(), "resume_session", task.PriorSessionID)
d.logger.Info("starting agent", "provider", provider, "task_id", task.ID, "workdir", env.WorkDir, "reused", task.PriorWorkDir != "" && env.WorkDir == task.PriorWorkDir, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String(), "resume_session", task.PriorSessionID)
session, err := backend.Execute(ctx, prompt, agent.ExecOptions{
Cwd: env.WorkDir,
@ -682,12 +710,10 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
return TaskResult{}, fmt.Errorf("%s returned empty output", provider)
}
return TaskResult{
Status: "completed",
Comment: result.Output,
BranchName: env.BranchName,
EnvType: string(env.Type),
SessionID: result.SessionID,
WorkDir: env.WorkDir,
Status: "completed",
Comment: result.Output,
SessionID: result.SessionID,
WorkDir: env.WorkDir,
}, nil
case "timeout":
return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout)
@ -700,6 +726,40 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
}
}
// repoDataToInfo converts daemon RepoData to repocache RepoInfo.
func repoDataToInfo(repos []RepoData) []repocache.RepoInfo {
info := make([]repocache.RepoInfo, len(repos))
for i, r := range repos {
info[i] = repocache.RepoInfo{URL: r.URL, Description: r.Description}
}
return info
}
// workspaceIDForRuntime returns the workspace ID that a runtime belongs to.
func (d *Daemon) workspaceIDForRuntime(runtimeID string) string {
d.mu.Lock()
defer d.mu.Unlock()
for _, ws := range d.workspaces {
for _, rid := range ws.runtimeIDs {
if rid == runtimeID {
return ws.workspaceID
}
}
}
return ""
}
func convertReposForEnv(repos []RepoData) []execenv.RepoContextForEnv {
if len(repos) == 0 {
return nil
}
result := make([]execenv.RepoContextForEnv, len(repos))
for i, r := range repos {
result[i] = execenv.RepoContextForEnv{URL: r.URL, Description: r.Description}
}
return result
}
func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv {
if len(skills) == 0 {
return nil

View file

@ -1,6 +1,6 @@
// Package execenv manages isolated per-task execution environments for the daemon.
// Each task gets its own directory with a git worktree (for code tasks) or plain
// directory (for non-code tasks), plus injected context files.
// Each task gets its own directory with injected context files. Repositories are
// checked out on demand by the agent via `multica repo checkout`.
package execenv
import (
@ -10,18 +10,15 @@ import (
"path/filepath"
)
// WorkspaceType indicates how the working directory was set up.
type WorkspaceType string
const (
WorkspaceTypeGitWorktree WorkspaceType = "git_worktree"
WorkspaceTypeDirectory WorkspaceType = "directory"
)
// RepoContextForEnv describes a workspace repo available for checkout.
type RepoContextForEnv struct {
URL string // remote URL
Description string // human-readable description
}
// PrepareParams holds all inputs needed to set up an execution environment.
type PrepareParams struct {
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
RepoPath string // source git repo path (for worktree creation), provided per-task by server
TaskID string // task UUID — used for directory name
AgentName string // for git branch naming only
Provider string // agent provider ("claude", "codex") — determines skill injection paths
@ -34,6 +31,7 @@ type TaskContextForEnv struct {
AgentName string
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
AgentSkills []SkillContextForEnv
Repos []RepoContextForEnv // workspace repos available for checkout
}
// SkillContextForEnv represents a skill to be written into the execution environment.
@ -55,18 +53,15 @@ type Environment struct {
RootDir string
// WorkDir is the directory to pass as Cwd to the agent ({RootDir}/workdir/).
WorkDir string
// Type indicates git_worktree or directory.
Type WorkspaceType
// BranchName is the git branch name (empty for directory type).
BranchName string
// CodexHome is the path to the per-task CODEX_HOME directory (set only for codex provider).
CodexHome string
gitRoot string // source repo root (for cleanup)
logger *slog.Logger // for cleanup logging
logger *slog.Logger // for cleanup logging
}
// Prepare creates an isolated execution environment for a task.
// The workdir starts empty (no repo checkouts). The agent checks out repos
// on demand via `multica repo checkout <url>`.
func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
if params.WorkspacesRoot == "" {
return nil, fmt.Errorf("execenv: workspaces root is required")
@ -95,35 +90,9 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
env := &Environment{
RootDir: envRoot,
WorkDir: workDir,
Type: WorkspaceTypeDirectory,
logger: logger,
}
// Detect git repo and set up worktree if available.
if params.RepoPath != "" {
if gitRoot, ok := detectGitRepo(params.RepoPath); ok {
branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID))
// Get the default branch as base ref.
baseRef := getDefaultBranch(gitRoot)
if err := setupGitWorktree(gitRoot, workDir, branchName, baseRef); err != nil {
logger.Warn("execenv: git worktree setup failed, falling back to directory mode", "error", err)
} else {
env.Type = WorkspaceTypeGitWorktree
env.BranchName = branchName
env.gitRoot = gitRoot
// Exclude injected directories from git tracking.
for _, pattern := range []string{".agent_context", ".claude", "CLAUDE.md", "AGENTS.md"} {
if err := excludeFromGit(workDir, pattern); err != nil {
logger.Warn("execenv: failed to exclude from git", "pattern", pattern, "error", err)
}
}
}
}
}
// Write context files into workdir (skills go to provider-native paths).
if err := writeContextFiles(workDir, params.Provider, params.Task); err != nil {
return nil, fmt.Errorf("execenv: write context files: %w", err)
@ -143,7 +112,7 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
env.CodexHome = codexHome
}
logger.Info("execenv: prepared env", "root", envRoot, "type", env.Type, "branch", env.BranchName)
logger.Info("execenv: prepared env", "root", envRoot, "repos_available", len(params.Task.Repos))
return env, nil
}
@ -157,23 +126,15 @@ func Reuse(workDir, provider string, task TaskContextForEnv, logger *slog.Logger
env := &Environment{
RootDir: filepath.Dir(workDir),
WorkDir: workDir,
Type: WorkspaceTypeDirectory,
logger: logger,
}
// Detect if this is a git worktree.
if gitRoot, ok := detectGitRepo(workDir); ok {
env.Type = WorkspaceTypeGitWorktree
env.BranchName = getDefaultBranch(workDir)
env.gitRoot = gitRoot
}
// Refresh context files (issue_context.md, skills).
if err := writeContextFiles(workDir, provider, task); err != nil {
logger.Warn("execenv: refresh context files failed", "error", err)
}
logger.Info("execenv: reusing env", "workdir", workDir, "type", env.Type, "branch", env.BranchName)
logger.Info("execenv: reusing env", "workdir", workDir)
return env
}
@ -185,11 +146,6 @@ func (env *Environment) Cleanup(removeAll bool) error {
return nil
}
// Remove git worktree first (must happen before directory deletion).
if env.Type == WorkspaceTypeGitWorktree && env.gitRoot != "" {
removeGitWorktree(env.gitRoot, env.WorkDir, env.BranchName, env.logger)
}
if removeAll {
if err := os.RemoveAll(env.RootDir); err != nil {
env.logger.Warn("execenv: cleanup removeAll failed", "error", err)

View file

@ -3,7 +3,6 @@ package execenv
import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@ -51,52 +50,31 @@ func TestSanitizeName(t *testing.T) {
}
}
func TestDetectGitRepo(t *testing.T) {
func TestRepoNameFromURL(t *testing.T) {
t.Parallel()
dir := t.TempDir()
cmd := exec.Command("git", "init", dir)
if err := cmd.Run(); err != nil {
t.Skipf("git not available: %v", err)
tests := []struct {
input, want string
}{
{"https://github.com/org/my-repo.git", "my-repo"},
{"https://github.com/org/my-repo", "my-repo"},
{"git@github.com:org/my-repo.git", "my-repo"},
{"https://github.com/org/repo/", "repo"},
{"my-repo", "my-repo"},
{"", "repo"},
}
root, ok := detectGitRepo(dir)
if !ok {
t.Fatal("expected git repo to be detected")
}
if root == "" {
t.Fatal("expected non-empty git root")
}
// Subdirectory should also detect.
subdir := filepath.Join(dir, "sub")
os.MkdirAll(subdir, 0o755)
root2, ok2 := detectGitRepo(subdir)
if !ok2 {
t.Fatal("expected subdirectory to detect git repo")
}
if root2 != root {
t.Fatalf("expected same root, got %q vs %q", root2, root)
}
}
func TestDetectGitRepoFalse(t *testing.T) {
t.Parallel()
dir := t.TempDir()
_, ok := detectGitRepo(dir)
if ok {
t.Fatal("expected non-git dir to return false")
for _, tt := range tests {
if got := repoNameFromURL(tt.input); got != tt.want {
t.Errorf("repoNameFromURL(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestPrepareDirectoryMode(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
reposRoot := t.TempDir() // not a git repo
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
AgentName: "Test Agent",
Task: TaskContextForEnv{
@ -111,13 +89,6 @@ func TestPrepareDirectoryMode(t *testing.T) {
}
defer env.Cleanup(true)
if env.Type != WorkspaceTypeDirectory {
t.Fatalf("expected directory type, got %s", env.Type)
}
if env.BranchName != "" {
t.Fatalf("expected empty branch name, got %s", env.BranchName)
}
// Verify directory structure.
for _, sub := range []string{"workdir", "output", "logs"} {
path := filepath.Join(env.RootDir, sub)
@ -145,67 +116,64 @@ func TestPrepareDirectoryMode(t *testing.T) {
if !strings.Contains(string(skillContent), "Be concise.") {
t.Fatal("SKILL.md missing content")
}
}
func TestPrepareGitWorktreeMode(t *testing.T) {
func TestPrepareWithRepoContext(t *testing.T) {
t.Parallel()
// Create a temporary git repo with an initial commit.
reposRoot := t.TempDir()
for _, args := range [][]string{
{"init", reposRoot},
{"-C", reposRoot, "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)
}
}
workspacesRoot := t.TempDir()
taskCtx := TaskContextForEnv{
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
Repos: []RepoContextForEnv{
{URL: "https://github.com/org/backend", Description: "Go backend"},
{URL: "https://github.com/org/frontend", Description: "React frontend"},
},
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
AgentName: "Code Reviewer",
Task: TaskContextForEnv{
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
},
Provider: "claude",
Task: taskCtx,
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
defer env.Cleanup(true)
if env.Type != WorkspaceTypeGitWorktree {
t.Fatalf("expected git_worktree type, got %s", env.Type)
}
if env.BranchName == "" {
t.Fatal("expected non-empty branch name")
}
if !strings.HasPrefix(env.BranchName, "agent/code-reviewer/") {
t.Fatalf("unexpected branch name: %s", env.BranchName)
// Inject runtime config (done separately in daemon, replicate here).
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
// Verify worktree is listed.
cmd := exec.Command("git", "-C", reposRoot, "worktree", "list")
out, err := cmd.Output()
// Workdir should be empty (no pre-created repo dirs).
entries, err := os.ReadDir(env.WorkDir)
if err != nil {
t.Fatalf("git worktree list failed: %v", err)
t.Fatalf("failed to read workdir: %v", err)
}
if !strings.Contains(string(out), "workdir") {
t.Fatalf("worktree not listed: %s", out)
for _, e := range entries {
name := e.Name()
if name != ".agent_context" && name != "CLAUDE.md" && name != ".claude" {
t.Errorf("unexpected entry in workdir: %s", name)
}
}
// Verify context file exists in workdir.
if _, err := os.Stat(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md")); os.IsNotExist(err) {
t.Fatal("expected .agent_context/issue_context.md to exist in workdir")
// CLAUDE.md should contain repo info.
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
if err != nil {
t.Fatalf("failed to read CLAUDE.md: %v", err)
}
s := string(content)
for _, want := range []string{
"multica repo checkout",
"https://github.com/org/backend",
"Go backend",
"https://github.com/org/frontend",
"React frontend",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
@ -348,56 +316,37 @@ func TestWriteContextFilesClaudeNativeSkills(t *testing.T) {
}
}
func TestCleanupGitWorktree(t *testing.T) {
func TestCleanupPreservesLogs(t *testing.T) {
t.Parallel()
// Create a temp git repo.
reposRoot := t.TempDir()
for _, args := range [][]string{
{"init", reposRoot},
{"-C", reposRoot, "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)
}
}
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
AgentName: "Cleanup Test",
Task: TaskContextForEnv{IssueID: "cleanup-test-id"},
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
AgentName: "Preserve Test",
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
branchName := env.BranchName
rootDir := env.RootDir
// Write something to logs/.
os.WriteFile(filepath.Join(env.RootDir, "logs", "test.log"), []byte("log data"), 0o644)
// Cleanup with removeAll=true.
if err := env.Cleanup(true); err != nil {
// Cleanup with removeAll=false.
if err := env.Cleanup(false); err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
// Verify env root is removed.
if _, err := os.Stat(rootDir); !os.IsNotExist(err) {
t.Fatal("expected env root to be removed")
// workdir should be gone.
if _, err := os.Stat(env.WorkDir); !os.IsNotExist(err) {
t.Fatal("expected workdir to be removed")
}
// Verify branch is deleted.
cmd := exec.Command("git", "-C", reposRoot, "branch", "--list", branchName)
out, _ := cmd.Output()
if strings.TrimSpace(string(out)) != "" {
t.Fatalf("expected branch %s to be deleted", branchName)
// logs should still exist.
logFile := filepath.Join(env.RootDir, "logs", "test.log")
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Fatal("expected logs/test.log to be preserved")
}
}
@ -437,12 +386,6 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
// Skills are now discovered natively — no path references in CLAUDE.md.
for _, absent := range []string{"go-conventions/SKILL.md", ".agent_context/skills/"} {
if strings.Contains(s, absent) {
t.Errorf("CLAUDE.md should NOT contain path %q — skills are discovered natively", absent)
}
}
}
func TestInjectRuntimeConfigCodex(t *testing.T) {
@ -512,41 +455,6 @@ func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
}
}
func TestCleanupPreservesLogs(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: t.TempDir(), // not a git repo
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
AgentName: "Preserve Test",
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
// Write something to logs/.
os.WriteFile(filepath.Join(env.RootDir, "logs", "test.log"), []byte("log data"), 0o644)
// Cleanup with removeAll=false.
if err := env.Cleanup(false); err != nil {
t.Fatalf("Cleanup failed: %v", err)
}
// workdir should be gone.
if _, err := os.Stat(env.WorkDir); !os.IsNotExist(err) {
t.Fatal("expected workdir to be removed")
}
// logs should still exist.
logFile := filepath.Join(env.RootDir, "logs", "test.log")
if _, err := os.Stat(logFile); os.IsNotExist(err) {
t.Fatal("expected logs/test.log to be preserved")
}
}
func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.

View file

@ -11,30 +11,65 @@ import (
"time"
)
// detectGitRepo checks if dir is inside a git repository.
// detectGitRepo checks if dir is inside a git repository (regular or bare).
// Returns the git root path and true if found.
func detectGitRepo(dir string) (string, bool) {
// Try regular repo first.
cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
return "", false
if out, err := cmd.Output(); err == nil {
return strings.TrimSpace(string(out)), true
}
return strings.TrimSpace(string(out)), true
// Try bare repo: git-dir is "." for bare repos when -C points at the repo.
cmd = exec.Command("git", "-C", dir, "rev-parse", "--is-bare-repository")
if out, err := cmd.Output(); err == nil && strings.TrimSpace(string(out)) == "true" {
return dir, true
}
return "", false
}
// getDefaultBranch returns the current branch name of the git repo, falling back to HEAD.
func getDefaultBranch(gitRoot string) string {
cmd := exec.Command("git", "-C", gitRoot, "symbolic-ref", "--short", "HEAD")
out, err := cmd.Output()
if err != nil {
return "HEAD"
// fetchOrigin runs `git fetch origin` to ensure the local repo has the latest remote refs.
func fetchOrigin(gitRoot string) error {
cmd := exec.Command("git", "-C", gitRoot, "fetch", "origin")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch origin: %s: %w", strings.TrimSpace(string(out)), err)
}
return strings.TrimSpace(string(out))
return nil
}
// getRemoteDefaultBranch returns "origin/<branch>" for the remote's default branch.
// Falls back to "origin/main", then "HEAD".
func getRemoteDefaultBranch(gitRoot string) string {
// Try symbolic-ref of origin/HEAD (set by `git clone` or `git remote set-head`).
cmd := exec.Command("git", "-C", gitRoot, "symbolic-ref", "refs/remotes/origin/HEAD")
if out, err := cmd.Output(); err == nil {
ref := strings.TrimSpace(string(out))
// ref looks like "refs/remotes/origin/main" — return "origin/main".
if strings.HasPrefix(ref, "refs/remotes/") {
return strings.TrimPrefix(ref, "refs/remotes/")
}
return ref
}
// Fallback: check if origin/main exists.
cmd = exec.Command("git", "-C", gitRoot, "rev-parse", "--verify", "origin/main")
if err := cmd.Run(); err == nil {
return "origin/main"
}
// Fallback: check if origin/master exists.
cmd = exec.Command("git", "-C", gitRoot, "rev-parse", "--verify", "origin/master")
if err := cmd.Run(); err == nil {
return "origin/master"
}
return "HEAD"
}
// setupGitWorktree creates a git worktree at worktreePath with a new branch.
func setupGitWorktree(gitRoot, worktreePath, branchName, baseRef string) error {
// Remove the workdir created by Prepare — git worktree add needs to create it.
// Remove the workdir created by caller — git worktree add needs to create it.
if err := os.Remove(worktreePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove placeholder workdir: %w", err)
}
@ -112,6 +147,32 @@ func excludeFromGit(worktreePath, pattern string) error {
return nil
}
// repoNameFromURL extracts a short directory name from a git remote URL.
// e.g. "https://github.com/org/my-repo.git" → "my-repo"
func repoNameFromURL(url string) string {
// Strip trailing slashes and .git suffix.
url = strings.TrimRight(url, "/")
url = strings.TrimSuffix(url, ".git")
// Take the last path segment.
if i := strings.LastIndex(url, "/"); i >= 0 {
url = url[i+1:]
}
// Also handle SSH-style "host:org/repo".
if i := strings.LastIndex(url, ":"); i >= 0 {
url = url[i+1:]
if j := strings.LastIndex(url, "/"); j >= 0 {
url = url[j+1:]
}
}
name := strings.TrimSpace(url)
if name == "" {
return "repo"
}
return name
}
// shortID returns the first 8 characters of a UUID string (dashes stripped).
func shortID(uuid string) string {
s := strings.ReplaceAll(uuid, "-", "")

View file

@ -54,14 +54,37 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
// Inject available repositories section.
if len(ctx.Repos) > 0 {
b.WriteString("## Repositories\n\n")
b.WriteString("The following code repositories are available in this workspace.\n")
b.WriteString("Use `multica repo checkout <url>` to check out a repository into your working directory.\n\n")
b.WriteString("| URL | Description |\n")
b.WriteString("|-----|-------------|\n")
for _, repo := range ctx.Repos {
desc := repo.Description
if desc == "" {
desc = "—"
}
fmt.Fprintf(&b, "| %s | %s |\n", repo.URL, desc)
}
b.WriteString("\nThe checkout command creates a git worktree with a dedicated branch. You can check out one or more repos as needed.\n\n")
}
b.WriteString("### Workflow\n")
b.WriteString("You are responsible for managing the issue status throughout your work.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue status %s in_progress`\n", ctx.IssueID)
b.WriteString("3. Read comments for additional context or human instructions\n")
b.WriteString("4. If the task requires code changes:\n")
b.WriteString(" a. Create a new branch\n")
b.WriteString(" b. Implement the changes and commit\n")
if len(ctx.Repos) > 0 {
b.WriteString(" a. Run `multica repo checkout <url>` to check out the appropriate repository\n")
b.WriteString(" b. `cd` into the checked-out directory\n")
b.WriteString(" c. Implement the changes and commit\n")
} else {
b.WriteString(" a. Create a new branch\n")
b.WriteString(" b. Implement the changes and commit\n")
}
b.WriteString(" c. Push the branch to the remote\n")
b.WriteString(" d. Create a pull request (decide the target branch based on the repo's conventions)\n")
fmt.Fprintf(&b, " e. Post the PR link as a comment: `multica issue comment add %s --content \"PR: <url>\"`\n", ctx.IssueID)

View file

@ -8,6 +8,8 @@ import (
"net/http"
"os"
"time"
"github.com/multica-ai/multica/server/internal/daemon/repocache"
)
// HealthResponse is returned by the daemon's local health endpoint.
@ -38,6 +40,15 @@ func (d *Daemon) listenHealth() (net.Listener, error) {
return ln, nil
}
// repoCheckoutRequest is the body of a POST /repo/checkout request.
type repoCheckoutRequest struct {
URL string `json:"url"`
WorkspaceID string `json:"workspace_id"`
WorkDir string `json:"workdir"`
AgentName string `json:"agent_name"`
TaskID string `json:"task_id"`
}
// serveHealth runs the health HTTP server on the given listener.
// Blocks until ctx is cancelled.
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
@ -73,6 +84,48 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req repoCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
if req.URL == "" {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
if req.WorkDir == "" {
http.Error(w, "workdir is required", http.StatusBadRequest)
return
}
if d.repoCache == nil {
http.Error(w, "repo cache not initialized", http.StatusInternalServerError)
return
}
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
WorkspaceID: req.WorkspaceID,
RepoURL: req.URL,
WorkDir: req.WorkDir,
AgentName: req.AgentName,
TaskID: req.TaskID,
})
if err != nil {
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
})
srv := &http.Server{Handler: mux}
go func() {

View file

@ -0,0 +1,345 @@
// Package repocache manages bare git clone caches for workspace repositories.
// The daemon uses these caches as the source for creating per-task worktrees.
package repocache
import (
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// RepoInfo describes a repository to cache.
type RepoInfo struct {
URL string
Description string
}
// CachedRepo describes a cached bare clone ready for worktree creation.
type CachedRepo struct {
URL string // remote URL
Description string // human-readable description
LocalPath string // absolute path to the bare clone
}
// Cache manages bare git clones for workspace repositories.
type Cache struct {
root string // base directory for all caches (e.g. ~/multica_workspaces/.repos)
logger *slog.Logger
mu sync.Mutex
}
// New creates a new repo cache rooted at the given directory.
func New(root string, logger *slog.Logger) *Cache {
return &Cache{root: root, logger: logger}
}
// Sync ensures all repos for a workspace are cloned (or fetched if already cached).
// Repos no longer in the list are left in place (cheap to keep, avoids re-cloning
// if a repo is temporarily removed and re-added).
func (c *Cache) Sync(workspaceID string, repos []RepoInfo) error {
c.mu.Lock()
defer c.mu.Unlock()
wsDir := filepath.Join(c.root, workspaceID)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
return fmt.Errorf("create workspace cache dir: %w", err)
}
var firstErr error
for _, repo := range repos {
if repo.URL == "" {
continue
}
barePath := filepath.Join(wsDir, bareDirName(repo.URL))
if isBareRepo(barePath) {
// Already cached — fetch latest.
c.logger.Info("repo cache: fetching", "url", repo.URL, "path", barePath)
if err := gitFetch(barePath); err != nil {
c.logger.Warn("repo cache: fetch failed", "url", repo.URL, "error", err)
if firstErr == nil {
firstErr = err
}
}
} else {
// Not cached — bare clone.
c.logger.Info("repo cache: cloning", "url", repo.URL, "path", barePath)
if err := gitCloneBare(repo.URL, barePath); err != nil {
c.logger.Error("repo cache: clone failed", "url", repo.URL, "error", err)
if firstErr == nil {
firstErr = err
}
}
}
}
return firstErr
}
// Lookup returns the local bare clone path for a repo URL within a workspace.
// Returns "" if not cached.
func (c *Cache) Lookup(workspaceID, url string) string {
barePath := filepath.Join(c.root, workspaceID, bareDirName(url))
if isBareRepo(barePath) {
return barePath
}
return ""
}
// Fetch runs `git fetch origin` on a cached bare clone to get latest refs.
func (c *Cache) Fetch(barePath string) error {
return gitFetch(barePath)
}
// bareDirName derives a directory name from a repo URL.
// e.g. "https://github.com/org/my-repo.git" → "my-repo.git"
func bareDirName(url string) string {
url = strings.TrimRight(url, "/")
name := url
if i := strings.LastIndex(url, "/"); i >= 0 {
name = url[i+1:]
}
// Handle SSH-style "host:org/repo".
if i := strings.LastIndex(name, ":"); i >= 0 {
name = name[i+1:]
if j := strings.LastIndex(name, "/"); j >= 0 {
name = name[j+1:]
}
}
if !strings.HasSuffix(name, ".git") {
name += ".git"
}
if name == ".git" {
name = "repo.git"
}
return name
}
// isBareRepo checks if a path looks like a bare git repository.
func isBareRepo(path string) bool {
// A bare repo has a HEAD file at the root.
_, err := os.Stat(filepath.Join(path, "HEAD"))
return err == nil
}
func gitCloneBare(url, dest string) error {
cmd := exec.Command("git", "clone", "--bare", url, dest)
if out, err := cmd.CombinedOutput(); err != nil {
// Clean up partial clone.
os.RemoveAll(dest)
return fmt.Errorf("git clone --bare: %s: %w", strings.TrimSpace(string(out)), err)
}
// Ensure fetch refspec is configured so `git fetch` updates local branches.
// `git clone --bare` doesn't set this by default.
cmd = exec.Command("git", "-C", dest, "config", "remote.origin.fetch", "+refs/heads/*:refs/heads/*")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("configure fetch refspec: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
func gitFetch(barePath string) error {
cmd := exec.Command("git", "-C", barePath, "fetch", "origin")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// WorktreeParams holds inputs for creating a worktree from a cached bare clone.
type WorktreeParams struct {
WorkspaceID string // workspace that owns the repo
RepoURL string // remote URL to look up in the cache
WorkDir string // parent directory for the worktree (e.g. task workdir)
AgentName string // for branch naming
TaskID string // for branch naming uniqueness
}
// WorktreeResult describes a successfully created worktree.
type WorktreeResult struct {
Path string `json:"path"` // absolute path to the worktree
BranchName string `json:"branch_name"` // git branch created for this worktree
}
// CreateWorktree looks up the bare cache for a repo, fetches latest, and creates
// a git worktree in the agent's working directory.
func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
barePath := c.Lookup(params.WorkspaceID, params.RepoURL)
if barePath == "" {
return nil, fmt.Errorf("repo not found in cache: %s (workspace: %s)", params.RepoURL, params.WorkspaceID)
}
// Fetch latest from origin.
if err := gitFetch(barePath); err != nil {
c.logger.Warn("repo checkout: fetch failed (continuing with cached state)", "url", params.RepoURL, "error", err)
}
// Determine the default branch to base the worktree on.
baseRef := getRemoteDefaultBranch(barePath)
// Build branch name: agent/{sanitized-name}/{short-task-id}
branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID))
// Derive directory name from repo URL.
dirName := repoNameFromURL(params.RepoURL)
worktreePath := filepath.Join(params.WorkDir, dirName)
// Create the worktree.
if err := createWorktree(barePath, worktreePath, branchName, baseRef); err != nil {
return nil, fmt.Errorf("create worktree: %w", err)
}
// Exclude agent context files from git tracking.
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude"} {
_ = excludeFromGit(worktreePath, pattern)
}
c.logger.Info("repo checkout: worktree created",
"url", params.RepoURL,
"path", worktreePath,
"branch", branchName,
"base", baseRef,
)
return &WorktreeResult{
Path: worktreePath,
BranchName: branchName,
}, nil
}
// createWorktree creates a git worktree at the given path with a new branch.
func createWorktree(gitRoot, worktreePath, branchName, baseRef string) error {
err := runWorktreeAdd(gitRoot, worktreePath, branchName, baseRef)
if err != nil && strings.Contains(err.Error(), "already exists") {
// Branch name collision: append timestamp and retry once.
branchName = fmt.Sprintf("%s-%d", branchName, time.Now().Unix())
err = runWorktreeAdd(gitRoot, worktreePath, branchName, baseRef)
}
return err
}
func runWorktreeAdd(gitRoot, worktreePath, branchName, baseRef string) error {
cmd := exec.Command("git", "-C", gitRoot, "worktree", "add", "-b", branchName, worktreePath, baseRef)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// getRemoteDefaultBranch returns the default branch ref for a bare repo.
// Tries HEAD, then falls back to "main", then "master".
func getRemoteDefaultBranch(barePath string) string {
// In a bare repo, HEAD points to the default branch.
cmd := exec.Command("git", "-C", barePath, "symbolic-ref", "HEAD")
if out, err := cmd.Output(); err == nil {
ref := strings.TrimSpace(string(out))
// ref looks like "refs/heads/main" — return just the branch name.
if strings.HasPrefix(ref, "refs/heads/") {
return strings.TrimPrefix(ref, "refs/heads/")
}
return ref
}
// Fallback: check if main branch exists.
cmd = exec.Command("git", "-C", barePath, "rev-parse", "--verify", "main")
if err := cmd.Run(); err == nil {
return "main"
}
cmd = exec.Command("git", "-C", barePath, "rev-parse", "--verify", "master")
if err := cmd.Run(); err == nil {
return "master"
}
return "HEAD"
}
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
func excludeFromGit(worktreePath, pattern string) error {
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("resolve git dir: %w", err)
}
gitDir := strings.TrimSpace(string(out))
if !filepath.IsAbs(gitDir) {
gitDir = filepath.Join(worktreePath, gitDir)
}
excludePath := filepath.Join(gitDir, "info", "exclude")
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
return fmt.Errorf("create info dir: %w", err)
}
existing, _ := os.ReadFile(excludePath)
if strings.Contains(string(existing), pattern) {
return nil
}
f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open exclude file: %w", err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "\n%s\n", pattern); err != nil {
return fmt.Errorf("write exclude pattern: %w", err)
}
return nil
}
// repoNameFromURL extracts a short directory name from a git remote URL.
// e.g. "https://github.com/org/my-repo.git" → "my-repo"
func repoNameFromURL(url string) string {
url = strings.TrimRight(url, "/")
url = strings.TrimSuffix(url, ".git")
if i := strings.LastIndex(url, "/"); i >= 0 {
url = url[i+1:]
}
if i := strings.LastIndex(url, ":"); i >= 0 {
url = url[i+1:]
if j := strings.LastIndex(url, "/"); j >= 0 {
url = url[j+1:]
}
}
name := strings.TrimSpace(url)
if name == "" {
return "repo"
}
return name
}
var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)
// sanitizeName produces a git-branch-safe name from a human-readable string.
func sanitizeName(name string) string {
s := strings.ToLower(strings.TrimSpace(name))
s = nonAlphanumeric.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if len(s) > 30 {
s = s[:30]
s = strings.TrimRight(s, "-")
}
if s == "" {
s = "agent"
}
return s
}
// shortID returns the first 8 characters of a UUID string (dashes stripped).
func shortID(uuid string) string {
s := strings.ReplaceAll(uuid, "-", "")
if len(s) > 8 {
return s[:8]
}
return s
}

View file

@ -0,0 +1,262 @@
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)
}

View file

@ -14,6 +14,12 @@ type Runtime struct {
Status string `json:"status"`
}
// RepoData holds repository information from the workspace.
type RepoData struct {
URL string `json:"url"`
Description string `json:"description"`
}
// Task represents a claimed task from the server.
// Agent data (name, skills) is populated by the claim endpoint.
type Task struct {
@ -22,6 +28,7 @@ type Task struct {
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
Agent *AgentData `json:"agent,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
}

View file

@ -81,6 +81,13 @@ func agentToResponse(a db.Agent) AgentResponse {
}
}
// RepoData holds repository information included in claim responses so the
// daemon can set up worktrees for each workspace repo.
type RepoData struct {
URL string `json:"url"`
Description string `json:"description"`
}
type AgentTaskResponse struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
@ -94,6 +101,7 @@ type AgentTaskResponse struct {
Result any `json:"result"`
Error *string `json:"error"`
Agent *TaskAgentData `json:"agent,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
CreatedAt string `json:"created_at"`
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue

View file

@ -51,7 +51,8 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "at least one runtime is required")
return
}
if _, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID)); err != nil {
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID))
if err != nil {
writeError(w, http.StatusNotFound, "workspace not found")
return
}
@ -106,7 +107,16 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
"runtimes": resp,
})
writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp})
// Include workspace repos so the daemon can cache them locally.
var repos []RepoData
if ws.Repos != nil {
json.Unmarshal(ws.Repos, &repos)
}
if repos == nil {
repos = []RepoData{}
}
writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp, "repos": repos})
}
// DaemonDeregister marks runtimes as offline when the daemon shuts down.
@ -217,6 +227,16 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
}
}
// Include workspace repos so the daemon can set up worktrees.
if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil {
if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil {
var repos []RepoData
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
resp.Repos = repos
}
}
}
// Look up the prior session for this (agent, issue) pair so the daemon
// can resume the Claude Code conversation context.
if prior, err := h.Queries.GetLastTaskSession(r.Context(), db.GetLastTaskSessionParams{