* 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
345 lines
10 KiB
Go
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
|
|
}
|