package execenv import ( "fmt" "log/slog" "os" "os/exec" "path/filepath" "regexp" "strings" "time" ) // 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") if out, err := cmd.Output(); err == nil { 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 } // 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 nil } // getRemoteDefaultBranch returns "origin/" 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 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) } err := runGitWorktreeAdd(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 = runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef) } return err } func runGitWorktreeAdd(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 } // removeGitWorktree removes a worktree and its branch. Best-effort: logs errors. func removeGitWorktree(gitRoot, worktreePath, branchName string, logger *slog.Logger) { // Remove the worktree. cmd := exec.Command("git", "-C", gitRoot, "worktree", "remove", "--force", worktreePath) if out, err := cmd.CombinedOutput(); err != nil { logger.Warn("execenv: git worktree remove failed", "output", strings.TrimSpace(string(out)), "error", err) } // Delete the branch (best-effort). if branchName != "" { cmd = exec.Command("git", "-C", gitRoot, "branch", "-D", branchName) if out, err := cmd.CombinedOutput(); err != nil { logger.Warn("execenv: git branch delete failed", "branch", branchName, "output", strings.TrimSpace(string(out)), "error", err) } } } // excludeFromGit adds a pattern to the worktree's .git/info/exclude file. func excludeFromGit(worktreePath, pattern string) error { // Resolve the actual git dir for this worktree. 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") // Ensure the info directory exists. if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil { return fmt.Errorf("create info dir: %w", err) } // Check if pattern is already present. 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 { // 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, "-", "") if len(s) > 8 { return s[:8] } return s } 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 }