multica/server/internal/daemon/repocache/cache.go
LinYushen 7df5750979
fix(daemon): update existing worktree to latest remote on reuse (#489)
* fix(daemon): update existing worktree to latest remote on reuse

When an agent receives a new task on the same issue, the execution
environment is reused and the repo worktree already exists on disk.
Previously, `multica repo checkout` would fail because `git worktree add`
cannot create a path that already exists — so the agent worked on stale
code from the prior task.

Now `CreateWorktree` detects existing worktrees and updates them:
fetch origin, reset working tree, then checkout a new branch from the
latest remote default branch. The previous task's branch is preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(daemon): propagate actual branch name and use correct ref in worktree reuse

- Return (string, error) from updateExistingWorktree so collision-retried
  branch name propagates to WorktreeResult
- Use baseRef directly instead of origin/baseRef — bare clone refspec maps
  remote branches to local refs, so remote-tracking refs may not exist
- Remove redundant fetch (worktree shares object store with bare clone)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:13:44 +08:00

415 lines
14 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. If a worktree already exists
// at the target path (reused environment), it updates the existing worktree to
// the latest remote default branch instead of failing.
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)
// If worktree already exists (reused environment from a prior task),
// update it to the latest remote code instead of creating a new one.
if isGitWorktree(worktreePath) {
actualBranch, err := updateExistingWorktree(worktreePath, branchName, baseRef)
if err != nil {
return nil, fmt.Errorf("update existing worktree: %w", err)
}
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude", ".config/opencode"} {
_ = excludeFromGit(worktreePath, pattern)
}
c.logger.Info("repo checkout: existing worktree updated",
"url", params.RepoURL,
"path", worktreePath,
"branch", actualBranch,
"base", baseRef,
)
return &WorktreeResult{
Path: worktreePath,
BranchName: actualBranch,
}, nil
}
// Create a new 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
}
// isGitWorktree checks if a path is an existing git worktree.
// Worktrees have a .git *file* (not directory) that points to the main repo.
func isGitWorktree(path string) bool {
info, err := os.Stat(filepath.Join(path, ".git"))
return err == nil && !info.IsDir()
}
// updateExistingWorktree resets the worktree to a clean state and checks out a
// new branch from the default branch. The caller is responsible for fetching
// the bare cache beforehand (worktrees share the same object store).
// Returns the actual branch name used (may differ from input on collision).
func updateExistingWorktree(worktreePath, branchName, baseRef string) (string, error) {
// Discard any leftover uncommitted changes from the previous task.
resetCmd := exec.Command("git", "-C", worktreePath, "reset", "--hard")
if out, err := resetCmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("git reset --hard: %s: %w", strings.TrimSpace(string(out)), err)
}
// Clean untracked files (e.g. build artifacts from previous task).
cleanCmd := exec.Command("git", "-C", worktreePath, "clean", "-fd")
if out, err := cleanCmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("git clean -fd: %s: %w", strings.TrimSpace(string(out)), err)
}
// Create a new branch from the latest default branch and switch to it.
// Use baseRef directly (not origin/baseRef) — the bare clone's fetch refspec
// maps remote branches to local refs, so remote-tracking refs may not exist.
checkoutCmd := exec.Command("git", "-C", worktreePath, "checkout", "-b", branchName, baseRef)
if out, err := checkoutCmd.CombinedOutput(); err != nil {
// Branch name collision: append timestamp and retry once.
if strings.Contains(string(out), "already exists") {
branchName = fmt.Sprintf("%s-%d", branchName, time.Now().Unix())
checkoutCmd = exec.Command("git", "-C", worktreePath, "checkout", "-b", branchName, baseRef)
if out2, err2 := checkoutCmd.CombinedOutput(); err2 != nil {
return "", fmt.Errorf("git checkout -b (retry): %s: %w", strings.TrimSpace(string(out2)), err2)
}
return branchName, nil
}
return "", fmt.Errorf("git checkout -b: %s: %w", strings.TrimSpace(string(out)), err)
}
return branchName, 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
}