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:
parent
ab4058b1e4
commit
cdc1ac708e
15 changed files with 1064 additions and 255 deletions
|
|
@ -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 <url>`.
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "-", "")
|
||||
|
|
|
|||
|
|
@ -54,14 +54,37 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
|||
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
|
||||
b.WriteString("- `multica issue update <id> [--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 <url>` 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 <url>` 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: <url>\"`\n", ctx.IssueID)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue