multica/server/internal/daemon/repocache/cache.go
Quake Wang 36db325d50
feat(daemon): add opencode as supported agent provider (#341)
* feat(daemon): add opencode as supported agent provider

Add opencode backend alongside claude and codex. The backend spawns
`opencode run --format json`, parses streaming JSON events (text,
tool_use, error, step_start/finish), and supports --prompt for system
prompts. Includes CLI detection, AGENTS.md runtime config, native skill
discovery via .config/opencode/skills/, and 21 tests covering handlers,
JSON parsing, and integration-level processEvents scenarios.

* chore: add .tool-versions to gitignore
2026-04-02 17:52:07 +08:00

345 lines
10 KiB
Go

// 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", ".config/opencode"} {
_ = 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
}