diff --git a/server/cmd/multica/cmd_repo.go b/server/cmd/multica/cmd_repo.go new file mode 100644 index 00000000..e5b692ad --- /dev/null +++ b/server/cmd/multica/cmd_repo.go @@ -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 ", + 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 +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 6c76f774..f44882e0 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -31,6 +31,7 @@ func init() { rootCmd.AddCommand(workspaceCmd) rootCmd.AddCommand(configCmd) rootCmd.AddCommand(issueCmd) + rootCmd.AddCommand(repoCmd) rootCmd.AddCommand(versionCmd) } diff --git a/server/internal/cli/config.go b/server/internal/cli/config.go index ceaffb34..f9f390e4 100644 --- a/server/internal/cli/config.go +++ b/server/internal/cli/config.go @@ -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() diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index 865ae177..878007d4 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -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 { diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index 22b7aecb..0c3d77ce 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -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 `. taskCtx := execenv.TaskContextForEnv{ IssueID: task.IssueID, AgentName: agentName, AgentInstructions: instructions, AgentSkills: convertSkillsForEnv(skills), + Repos: convertReposForEnv(task.Repos), } env, err := execenv.Prepare(execenv.PrepareParams{ WorkspacesRoot: d.cfg.WorkspacesRoot, @@ -623,11 +647,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/. @@ -643,7 +671,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, "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, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String(), "resume_session", task.PriorSessionID) session, err := backend.Execute(ctx, prompt, agent.ExecOptions{ Cwd: env.WorkDir, @@ -675,12 +703,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) @@ -693,6 +719,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 diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go index 98a16e34..861dd977 100644 --- a/server/internal/daemon/execenv/execenv.go +++ b/server/internal/daemon/execenv/execenv.go @@ -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 `. 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 } @@ -155,11 +124,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) diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go index f76d75b6..f44ef67b 100644 --- a/server/internal/daemon/execenv/execenv_test.go +++ b/server/internal/daemon/execenv/execenv_test.go @@ -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. diff --git a/server/internal/daemon/execenv/git.go b/server/internal/daemon/execenv/git.go index 97420ceb..7e87f59d 100644 --- a/server/internal/daemon/execenv/git.go +++ b/server/internal/daemon/execenv/git.go @@ -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/" 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, "-", "") diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index 11c484d0..aad63fe0 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -54,14 +54,37 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("- `multica issue status ` — Update issue status (todo, in_progress, in_review, done, blocked)\n") b.WriteString("- `multica issue update [--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 ` 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 ` 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: \"`\n", ctx.IssueID) diff --git a/server/internal/daemon/health.go b/server/internal/daemon/health.go index 62d7b14f..4f06d6e7 100644 --- a/server/internal/daemon/health.go +++ b/server/internal/daemon/health.go @@ -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() { diff --git a/server/internal/daemon/repocache/cache.go b/server/internal/daemon/repocache/cache.go new file mode 100644 index 00000000..ef8c5160 --- /dev/null +++ b/server/internal/daemon/repocache/cache.go @@ -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 +} diff --git a/server/internal/daemon/repocache/cache_test.go b/server/internal/daemon/repocache/cache_test.go new file mode 100644 index 00000000..6be21470 --- /dev/null +++ b/server/internal/daemon/repocache/cache_test.go @@ -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) +} diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index bc6fcb75..b6cf59e5 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -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 } diff --git a/server/internal/handler/agent.go b/server/internal/handler/agent.go index 20434c37..a58acbce 100644 --- a/server/internal/handler/agent.go +++ b/server/internal/handler/agent.go @@ -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 } diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go index a00d998f..fd938ba0 100644 --- a/server/internal/handler/daemon.go +++ b/server/internal/handler/daemon.go @@ -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{