feat(daemon): agent-driven repo checkout with bare clone cache

Agents now decide which repo to use based on issue context and check out
repos on demand via `multica repo checkout <url>`. Workspace repos are
cached locally as bare clones for fast worktree creation.

Key changes:
- Add repocache package for bare clone management (clone, fetch, worktree)
- Add `multica repo checkout` CLI command that talks to local daemon
- Add POST /repo/checkout endpoint on daemon health server
- Pass workspace repos metadata through register + task claim responses
- Remove pre-created worktrees from execenv (workdir starts empty)
- Update CLAUDE.md template to instruct agents to use `multica repo checkout`
- Pass MULTICA_DAEMON_PORT, WORKSPACE_ID, AGENT_NAME, TASK_ID env vars to agent
This commit is contained in:
Jiayuan 2026-03-29 19:37:48 +08:00
parent ab4058b1e4
commit cdc1ac708e
15 changed files with 1064 additions and 255 deletions

View file

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